diff --git a/application/src/main/data/upgrade/lts/schema_update.sql b/application/src/main/data/upgrade/lts/schema_update.sql new file mode 100644 index 0000000000..790dc50b52 --- /dev/null +++ b/application/src/main/data/upgrade/lts/schema_update.sql @@ -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 diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 67ef7f6ab1..4266c97303 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -36,6 +36,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.SmsService; @@ -691,6 +692,10 @@ public class ActorSystemContext { @Getter private MqttClientSettings mqttClientSettings; + @Autowired(required = false) + @Getter + private TbHttpClientSettings tbHttpClientSettings; + @Getter @Setter private TbActorSystem actorSystem; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index f93bb34af8..76f79bf1f9 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -27,6 +27,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; @@ -1068,6 +1069,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getMqttClientSettings(); } + @Override + public TbHttpClientSettings getTbHttpClientSettings() { + return mainCtx.getTbHttpClientSettings(); + } + private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) { TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("ruleNodeId", ruleNodeId.toString()); diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java index d1c036d38e..867efa67cc 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.config; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; @@ -62,12 +63,14 @@ import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.exception.ThingsboardCredentialsExpiredResponse; import org.thingsboard.server.exception.ThingsboardErrorResponse; import org.thingsboard.server.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.auth.rest.LoginResponse; +import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.nio.ByteBuffer; @@ -76,6 +79,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.Deque; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -115,6 +119,8 @@ public class SwaggerConfiguration { // Keyed by the schema name that swagger-core generates (see resolveSchemaName). private final Map> schemaPropertyOrders = new ConcurrentHashMap<>(); private final Map> schemaOwnProps = new ConcurrentHashMap<>(); + // Tracks schema name → fully-qualified class names to detect collisions. + private final Map> schemaNameToClasses = new ConcurrentHashMap<>(); @Value("${swagger.api_path:/api/**}") private String apiPath; @@ -294,6 +300,19 @@ public class SwaggerConfiguration { @Lazy(false) ModelConverter mapAwareConverter() { return (type, context, chain) -> { + // Strip field-level @JsonIgnoreProperties from context annotations so it + // doesn't pollute the global schema. The OpenAPI schema should show all + // properties; field-level ignore is a serialization concern only. + Annotation[] ctxAnnotations = type.getCtxAnnotations(); + if (ctxAnnotations != null) { + Annotation[] filtered = Arrays.stream(ctxAnnotations) + .filter(a -> !(a instanceof JsonIgnoreProperties)) + .toArray(Annotation[]::new); + if (filtered.length != ctxAnnotations.length) { + type.ctxAnnotations(filtered); + } + } + JavaType javaType = Json.mapper().constructType(type.getType()); if (javaType != null) { Class cls = javaType.getRawClass(); @@ -326,6 +345,10 @@ public class SwaggerConfiguration { try { var beanDesc = Json.mapper().getSerializationConfig().introspect(javaType); String schemaName = resolveSchemaName(javaType); + Set classes = schemaNameToClasses.computeIfAbsent(schemaName, k -> ConcurrentHashMap.newKeySet()); + if (classes.add(cls.getName()) && classes.size() > 1) { + log.error("Duplicate OpenAPI schema name '{}' mapped by: {}. Use @Schema(name = ...) to disambiguate.", schemaName, classes); + } schemaPropertyOrders.put(schemaName, resolvePropertyOrder(cls, beanDesc)); Set ownProps = computeOwnPropNames(cls, beanDesc); if (!ownProps.isEmpty()) { @@ -355,7 +378,8 @@ public class SwaggerConfiguration { .addSchemas("LoginResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginResponse.class)).schema) .addSchemas("ThingsboardErrorResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardErrorResponse.class)).schema) .addSchemas("ThingsboardCredentialsExpiredResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardCredentialsExpiredResponse.class)).schema) - .addSchemas("ThingsboardErrorCode", errorCodeSchema); + .addSchemas("ThingsboardErrorCode", errorCodeSchema) + .addSchemas("AiChatModelConfig", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(AiChatModelConfig.class)).schema); } private OperationCustomizer operationCustomizer() { @@ -372,6 +396,19 @@ public class SwaggerConfiguration { var apiKeyRequirement = createSecurityRequirement(API_KEY_SCHEME); return openAPI -> { + // Fail fast on duplicate schema names — two different classes resolving to the same + // OpenAPI schema name causes one to silently overwrite the other. + List duplicates = schemaNameToClasses.entrySet().stream() + .filter(e -> e.getValue().size() > 1) + .map(e -> "'" + e.getKey() + "' mapped by: " + e.getValue()) + .sorted() + .toList(); + if (!duplicates.isEmpty()) { + throw new IllegalStateException( + "Duplicate OpenAPI schema names detected. Use @Schema(name = ...) to disambiguate:\n " + + String.join("\n ", duplicates)); + } + var paths = openAPI.getPaths(); paths.entrySet().stream() .peek(entry -> { @@ -407,6 +444,64 @@ public class SwaggerConfiguration { } }); + // Springdoc creates duplicate schemas with an "Object" suffix when a type is + // resolved through multiple inheritance paths or via generic type resolution. + // Remove the "*Object" duplicate when the base schema exists (either + // pre-registered in addDefaultSchemas or generated by springdoc). + for (String name : new ArrayList<>(schemas.keySet())) { + if (!name.endsWith("Object")) continue; + String baseName = name.substring(0, name.length() - "Object".length()); + if (!schemas.containsKey(baseName)) continue; + + schemas.remove(name); + String refToRemove = "#/components/schemas/" + name; + schemas.values().forEach(s -> { + if (s.getAllOf() != null) { + s.getAllOf().removeIf(allOfEntry -> refToRemove.equals(((Schema) allOfEntry).get$ref())); + } + }); + log.debug("Removed duplicate schema '{}' (base '{}' exists)", name, baseName); + } + + // Remove duplicate or redundant inline entries in allOf. Springdoc can + // generate multiple inline property blocks when resolving a type through + // multiple parent paths (e.g. record + sealed interface). One block may be + // a strict subset of another (same properties, but the superset has extras + // like "modelType"). Keep only the superset in that case. + schemas.values().forEach(schema -> { + if (schema.getAllOf() != null && schema.getAllOf().size() > 1) { + List allOf = schema.getAllOf(); + Set redundant = new HashSet<>(); + for (int i = 0; i < allOf.size(); i++) { + if (redundant.contains(i)) continue; + Schema a = allOf.get(i); + if (a.get$ref() != null || a.getProperties() == null) continue; + for (int j = i + 1; j < allOf.size(); j++) { + if (redundant.contains(j)) continue; + Schema b = allOf.get(j); + if (b.get$ref() != null || b.getProperties() == null) continue; + if (a.getProperties().entrySet().containsAll(b.getProperties().entrySet())) { + redundant.add(j); // b is a subset of a + } else if (b.getProperties().entrySet().containsAll(a.getProperties().entrySet())) { + redundant.add(i); // a is a subset of b + break; + } + } + } + if (!redundant.isEmpty()) { + List filtered = new ArrayList<>(); + for (int i = 0; i < allOf.size(); i++) { + if (!redundant.contains(i)) { + filtered.add(allOf.get(i)); + } + } + allOf.clear(); + allOf.addAll(filtered); + } + } + }); + + // Fix polymorphic properties: replace inline oneOf with base type $ref schemas.values().forEach(schema -> { replaceInlineOneOfProperties(schema, schemas); @@ -546,6 +641,16 @@ public class SwaggerConfiguration { log.debug("Replaced oneOf in additionalProperties with $ref to {} in property {}", baseType, propName); } } + // Check if additionalProperties is an array whose items has oneOf (e.g. Map>) + if (additionalProps.getItems() != null && additionalProps.getItems().getOneOf() != null && !additionalProps.getItems().getOneOf().isEmpty()) { + String baseType = findBaseTypeForOneOf(allSchemas, additionalProps.getItems().getOneOf()); + if (baseType != null) { + Schema refSchema = new Schema<>(); + refSchema.set$ref("#/components/schemas/" + baseType); + additionalProps.setItems(refSchema); + log.debug("Replaced oneOf in additionalProperties.items with $ref to {} in property {}", baseType, propName); + } + } } // If property has oneOf, try to find the base discriminated type @@ -799,7 +904,13 @@ public class SwaggerConfiguration { * This matches the naming convention used by swagger-core's {@code TypeNameResolver}. */ private static String resolveSchemaName(JavaType javaType) { - StringBuilder sb = new StringBuilder(javaType.getRawClass().getSimpleName()); + Class cls = javaType.getRawClass(); + io.swagger.v3.oas.annotations.media.Schema schemaAnnotation = + cls.getAnnotation(io.swagger.v3.oas.annotations.media.Schema.class); + if (schemaAnnotation != null && !schemaAnnotation.name().isEmpty()) { + return schemaAnnotation.name(); + } + StringBuilder sb = new StringBuilder(cls.getSimpleName()); if (javaType.hasGenericTypes()) { for (int i = 0; i < javaType.containedTypeCount(); i++) { JavaType param = javaType.containedType(i); @@ -904,8 +1015,14 @@ public class SwaggerConfiguration { // Map backing field names to their JSON property names (respects @JsonProperty) Map fieldToJsonName = new LinkedHashMap<>(); for (var prop : beanDesc.findProperties()) { - if (prop.getField() != null && prop.couldSerialize()) { - fieldToJsonName.put(prop.getField().getName(), prop.getName()); + if (prop.couldSerialize()) { + if (prop.getField() != null) { + fieldToJsonName.put(prop.getField().getName(), prop.getName()); + } else { + // For transient fields, Jackson may not associate the field with the property. + // Fall back to using the property name as the field name key. + fieldToJsonName.putIfAbsent(prop.getName(), prop.getName()); + } } } @@ -940,4 +1057,4 @@ public class SwaggerConfiguration { .value(node); } -} +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java new file mode 100644 index 0000000000..a5ad4e8742 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java @@ -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; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index ae8305c070..8a113fb424 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; +import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; @@ -77,6 +78,7 @@ public class AlarmCommentController extends BaseController { AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); alarmComment.setAlarmId(alarmId); + alarmComment.setType(AlarmCommentType.OTHER); return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); } diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmRuleController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmRuleController.java new file mode 100644 index 0000000000..957d05d1f4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmRuleController.java @@ -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 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 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 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 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 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 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 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; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 48e0f11552..56f1cf6a4b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -71,6 +71,7 @@ import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeInfo; @@ -681,6 +682,17 @@ public abstract class BaseController { return entity; } + protected void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException { + for (EntityId referencedEntityId : calculatedFieldConfig.getReferencedEntities()) { + EntityType refEntityType = referencedEntityId.getEntityType(); + switch (refEntityType) { + case TENANT -> {} + case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); + default -> throw new IllegalArgumentException("Unsupported referenced entity type: '" + refEntityType + "'."); + } + } + } + Device checkDeviceId(DeviceId deviceId, Operation operation) throws ThingsboardException { return checkEntityId(deviceId, deviceService::findDeviceById, operation); } diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index 14a9728c6b..84ff4b49a4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.controller; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; @@ -24,10 +23,7 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.lang3.ObjectUtils; -import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.util.MultiValueMap; @@ -40,13 +36,6 @@ 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.common.util.JacksonUtil; -import org.thingsboard.script.api.tbel.TbelCfArg; -import org.thingsboard.script.api.tbel.TbelCfCtx; -import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; -import org.thingsboard.script.api.tbel.TbelCfTsDoubleVal; -import org.thingsboard.script.api.tbel.TbelCfTsRollingArg; -import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.cf.CalculatedField; @@ -65,21 +54,15 @@ 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.cf.ctx.state.CalculatedFieldTbelScriptEngine; 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.ArrayList; -import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; -import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeUnit; import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; @@ -98,17 +81,13 @@ import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LI @TbCoreComponent @RequestMapping("/api") @RequiredArgsConstructor -@Slf4j public class CalculatedFieldController extends BaseController { private final TbCalculatedFieldService tbCalculatedFieldService; private final EventService eventService; - private final TbelInvokeService tbelInvokeService; public static final String CALCULATED_FIELD_ID = "calculatedFieldId"; - public static final int TIMEOUT = 20; - private static final String TEST_SCRIPT_EXPRESSION = "Execute the Script expression and return the result. The format of request: \n\n" + MARKDOWN_CODE_BLOCK_START @@ -171,13 +150,13 @@ public class CalculatedFieldController extends BaseController { @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @GetMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}) public PageData getCalculatedFieldsByEntityIdV1(@PathVariable("entityType") String entityType, - @PathVariable("entityId") String entityIdStr, - @RequestParam int pageSize, - @RequestParam int page, - @RequestParam(required = false) CalculatedFieldType type, - @RequestParam(required = false) String textSearch, - @RequestParam(required = false) String sortProperty, - @RequestParam(required = false) String sortOrder) throws ThingsboardException { + @PathVariable("entityId") String entityIdStr, + @RequestParam int pageSize, + @RequestParam int page, + @RequestParam(required = false) CalculatedFieldType type, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String sortProperty, + @RequestParam(required = false) String sortOrder) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); checkParameter("entityId", entityIdStr); EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr); @@ -305,86 +284,9 @@ public class CalculatedFieldController extends BaseController { @PostMapping("/calculatedField/testScript") public JsonNode testCalculatedFieldScript( @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test calculated field TBEL expression.") - @RequestBody JsonNode inputParams) { - String expression = inputParams.get("expression").asText(); - Map arguments = Objects.requireNonNullElse( - JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() {}), - Collections.emptyMap() - ); - - ArrayList ctxAndArgNames = new ArrayList<>(arguments.size() + 1); - ctxAndArgNames.add("ctx"); - ctxAndArgNames.addAll(arguments.keySet()); - - String output = ""; - String errorText = ""; - - CalculatedFieldTbelScriptEngine engine = null; - try { - if (tbelInvokeService == null) { - throw new IllegalArgumentException("TBEL script engine is disabled!"); - } - - engine = new CalculatedFieldTbelScriptEngine( - getTenantId(), - tbelInvokeService, - expression, - ctxAndArgNames.toArray(String[]::new) - ); - - Object[] args = new Object[ctxAndArgNames.size()]; - args[0] = new TbelCfCtx(arguments, getLatestTimestamp(arguments)); - for (int i = 1; i < ctxAndArgNames.size(); i++) { - var arg = arguments.get(ctxAndArgNames.get(i)); - if (arg instanceof TbelCfSingleValueArg svArg) { - args[i] = svArg.getValue(); - } else { - args[i] = arg; - } - } - - JsonNode json = engine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); - output = JacksonUtil.toString(json); - } catch (Exception e) { - log.error("Error evaluating expression", e); - Throwable rootCause = ExceptionUtils.getRootCause(e); - errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getMessage(), e.getClass().getSimpleName()); - } finally { - if (engine != null) { - engine.destroy(); - } - } - return JacksonUtil.newObjectNode() - .put("output", output) - .put("error", errorText); - } - - private long getLatestTimestamp(Map arguments) { - long lastUpdateTimestamp = -1; - for (TbelCfArg entry : arguments.values()) { - if (entry instanceof TbelCfSingleValueArg singleValueArg) { - long ts = singleValueArg.getTs(); - lastUpdateTimestamp = Math.max(lastUpdateTimestamp, ts); - } else if (entry instanceof TbelCfTsRollingArg tsRollingArg) { - long maxTs = tsRollingArg.getValues().stream().mapToLong(TbelCfTsDoubleVal::getTs).max().orElse(-1); - lastUpdateTimestamp = Math.max(lastUpdateTimestamp, maxTs); - } - } - return lastUpdateTimestamp == -1 ? System.currentTimeMillis() : lastUpdateTimestamp; - } - - private void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException { - Set referencedEntityIds = calculatedFieldConfig.getReferencedEntities(); - for (EntityId referencedEntityId : referencedEntityIds) { - EntityType entityType = referencedEntityId.getEntityType(); - switch (entityType) { - case TENANT -> { - return; - } - case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); - default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); - } - } + @RequestBody JsonNode inputParams) throws ThingsboardException { + checkParameter("expression", inputParams.has("expression") ? inputParams.get("expression").asText() : null); + return tbCalculatedFieldService.executeTestScript(getTenantId(), inputParams); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index ab5c1c0ae4..04afb1330e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -444,106 +444,6 @@ public class ControllerConstants { " * 'BOOLEAN' - used for boolean values. Operations: EQUAL, NOT_EQUAL;\n" + " * 'DATE_TIME' - similar to numeric, transforms value to milliseconds since epoch. Operations: EQUAL, NOT_EQUAL, GREATER, LESS, GREATER_OR_EQUAL, LESS_OR_EQUAL; \n"; - protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_SPECIFIC_TIME_EXAMPLE = MARKDOWN_CODE_BLOCK_START + - "{\n" + - " \"schedule\":{\n" + - " \"type\":\"SPECIFIC_TIME\",\n" + - " \"endsOn\":64800000,\n" + - " \"startsOn\":43200000,\n" + - " \"timezone\":\"Europe/Kiev\",\n" + - " \"daysOfWeek\":[\n" + - " 1,\n" + - " 3,\n" + - " 5\n" + - " ]\n" + - " }\n" + - "}" + - MARKDOWN_CODE_BLOCK_END; - protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_CUSTOM_EXAMPLE = MARKDOWN_CODE_BLOCK_START + - "{\n" + - " \"schedule\":{\n" + - " \"type\":\"CUSTOM\",\n" + - " \"items\":[\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":1\n" + - " },\n" + - " {\n" + - " \"endsOn\":64800000,\n" + - " \"enabled\":true,\n" + - " \"startsOn\":43200000,\n" + - " \"dayOfWeek\":2\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":3\n" + - " },\n" + - " {\n" + - " \"endsOn\":57600000,\n" + - " \"enabled\":true,\n" + - " \"startsOn\":36000000,\n" + - " \"dayOfWeek\":4\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":5\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":6\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":7\n" + - " }\n" + - " ],\n" + - " \"timezone\":\"Europe/Kiev\"\n" + - " }\n" + - "}" + - MARKDOWN_CODE_BLOCK_END; - protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_ALWAYS_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "\"schedule\": null" + MARKDOWN_CODE_BLOCK_END; - - protected static final String DEVICE_PROFILE_ALARM_CONDITION_REPEATING_EXAMPLE = MARKDOWN_CODE_BLOCK_START + - "{\n" + - " \"spec\":{\n" + - " \"type\":\"REPEATING\",\n" + - " \"predicate\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":5,\n" + - " \"dynamicValue\":{\n" + - " \"inherit\":true,\n" + - " \"sourceType\":\"CURRENT_DEVICE\",\n" + - " \"sourceAttribute\":\"tempAttr\"\n" + - " }\n" + - " }\n" + - " }\n" + - "}" + - MARKDOWN_CODE_BLOCK_END; - - protected static final String DEVICE_PROFILE_ALARM_CONDITION_DURATION_EXAMPLE = MARKDOWN_CODE_BLOCK_START + - "{\n" + - " \"spec\":{\n" + - " \"type\":\"DURATION\",\n" + - " \"unit\":\"MINUTES\",\n" + - " \"predicate\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":30,\n" + - " \"dynamicValue\":null\n" + - " }\n" + - " }\n" + - "}" + - MARKDOWN_CODE_BLOCK_END; - protected static final String RELATION_TYPE_PARAM_DESCRIPTION = "A string value representing relation type between entities. For example, 'Contains', 'Manages'. It can be any string value."; protected static final String RELATION_TYPE_GROUP_PARAM_DESCRIPTION = "A string value representing relation type group. For example, 'COMMON'"; @@ -1328,8 +1228,6 @@ public class ControllerConstants { ALARM_FILTER_KEY + FILTER_VALUE_TYPE + NEW_LINE + DEVICE_PROFILE_FILTER_PREDICATE + NEW_LINE; protected static final String DEFAULT_DEVICE_PROFILE_DATA_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "{\n" + - " \"alarms\":[\n" + - " ],\n" + " \"configuration\":{\n" + " \"type\":\"DEFAULT\"\n" + " },\n" + @@ -1343,219 +1241,6 @@ public class ControllerConstants { "}" + MARKDOWN_CODE_BLOCK_END; protected static final String CUSTOM_DEVICE_PROFILE_DATA_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "{\n" + - " \"alarms\":[\n" + - " {\n" + - " \"id\":\"2492b935-1226-59e9-8615-17d8978a4f93\",\n" + - " \"alarmType\":\"Temperature Alarm\",\n" + - " \"clearRule\":{\n" + - " \"schedule\":null,\n" + - " \"condition\":{\n" + - " \"spec\":{\n" + - " \"type\":\"SIMPLE\"\n" + - " },\n" + - " \"condition\":[\n" + - " {\n" + - " \"key\":{\n" + - " \"key\":\"temperature\",\n" + - " \"type\":\"TIME_SERIES\"\n" + - " },\n" + - " \"value\":null,\n" + - " \"predicate\":{\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":30.0,\n" + - " \"dynamicValue\":null\n" + - " },\n" + - " \"operation\":\"LESS\"\n" + - " },\n" + - " \"valueType\":\"NUMERIC\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"dashboardId\":null,\n" + - " \"alarmDetails\":null\n" + - " },\n" + - " \"propagate\":false,\n" + - " \"createRules\":{\n" + - " \"MAJOR\":{\n" + - " \"schedule\":{\n" + - " \"type\":\"SPECIFIC_TIME\",\n" + - " \"endsOn\":64800000,\n" + - " \"startsOn\":43200000,\n" + - " \"timezone\":\"Europe/Kiev\",\n" + - " \"daysOfWeek\":[\n" + - " 1,\n" + - " 3,\n" + - " 5\n" + - " ]\n" + - " },\n" + - " \"condition\":{\n" + - " \"spec\":{\n" + - " \"type\":\"DURATION\",\n" + - " \"unit\":\"MINUTES\",\n" + - " \"predicate\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":30,\n" + - " \"dynamicValue\":null\n" + - " }\n" + - " },\n" + - " \"condition\":[\n" + - " {\n" + - " \"key\":{\n" + - " \"key\":\"temperature\",\n" + - " \"type\":\"TIME_SERIES\"\n" + - " },\n" + - " \"value\":null,\n" + - " \"predicate\":{\n" + - " \"type\":\"COMPLEX\",\n" + - " \"operation\":\"OR\",\n" + - " \"predicates\":[\n" + - " {\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":50.0,\n" + - " \"dynamicValue\":null\n" + - " },\n" + - " \"operation\":\"LESS_OR_EQUAL\"\n" + - " },\n" + - " {\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":30.0,\n" + - " \"dynamicValue\":null\n" + - " },\n" + - " \"operation\":\"GREATER\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"valueType\":\"NUMERIC\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"dashboardId\":null,\n" + - " \"alarmDetails\":null\n" + - " },\n" + - " \"WARNING\":{\n" + - " \"schedule\":{\n" + - " \"type\":\"CUSTOM\",\n" + - " \"items\":[\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":1\n" + - " },\n" + - " {\n" + - " \"endsOn\":64800000,\n" + - " \"enabled\":true,\n" + - " \"startsOn\":43200000,\n" + - " \"dayOfWeek\":2\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":3\n" + - " },\n" + - " {\n" + - " \"endsOn\":57600000,\n" + - " \"enabled\":true,\n" + - " \"startsOn\":36000000,\n" + - " \"dayOfWeek\":4\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":5\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":6\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":7\n" + - " }\n" + - " ],\n" + - " \"timezone\":\"Europe/Kiev\"\n" + - " },\n" + - " \"condition\":{\n" + - " \"spec\":{\n" + - " \"type\":\"REPEATING\",\n" + - " \"predicate\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":5,\n" + - " \"dynamicValue\":null\n" + - " }\n" + - " },\n" + - " \"condition\":[\n" + - " {\n" + - " \"key\":{\n" + - " \"key\":\"tempConstant\",\n" + - " \"type\":\"CONSTANT\"\n" + - " },\n" + - " \"value\":30,\n" + - " \"predicate\":{\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":0.0,\n" + - " \"dynamicValue\":{\n" + - " \"inherit\":false,\n" + - " \"sourceType\":\"CURRENT_DEVICE\",\n" + - " \"sourceAttribute\":\"tempThreshold\"\n" + - " }\n" + - " },\n" + - " \"operation\":\"EQUAL\"\n" + - " },\n" + - " \"valueType\":\"NUMERIC\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"dashboardId\":null,\n" + - " \"alarmDetails\":null\n" + - " },\n" + - " \"CRITICAL\":{\n" + - " \"schedule\":null,\n" + - " \"condition\":{\n" + - " \"spec\":{\n" + - " \"type\":\"SIMPLE\"\n" + - " },\n" + - " \"condition\":[\n" + - " {\n" + - " \"key\":{\n" + - " \"key\":\"temperature\",\n" + - " \"type\":\"TIME_SERIES\"\n" + - " },\n" + - " \"value\":null,\n" + - " \"predicate\":{\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":50.0,\n" + - " \"dynamicValue\":null\n" + - " },\n" + - " \"operation\":\"GREATER\"\n" + - " },\n" + - " \"valueType\":\"NUMERIC\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"dashboardId\":null,\n" + - " \"alarmDetails\":null\n" + - " }\n" + - " },\n" + - " \"propagateRelationTypes\":null\n" + - " }\n" + - " ],\n" + " \"configuration\":{\n" + " \"type\":\"DEFAULT\"\n" + " },\n" + @@ -1577,40 +1262,11 @@ public class ControllerConstants { " }\n" + "}" + MARKDOWN_CODE_BLOCK_END; protected static final String DEVICE_PROFILE_DATA_DEFINITION = NEW_LINE + "# Device profile data definition" + NEW_LINE + - "Device profile data object contains alarm rules configuration, device provision strategy and transport type configuration for device connectivity. Let's review some examples. " + + "Device profile data object contains device provision strategy and transport type configuration for device connectivity. Let's review some examples. " + "First one is the default device profile data configuration and second one - the custom one. " + NEW_LINE + DEFAULT_DEVICE_PROFILE_DATA_EXAMPLE + NEW_LINE + CUSTOM_DEVICE_PROFILE_DATA_EXAMPLE + NEW_LINE + "Let's review some specific objects examples related to the device profile configuration:"; - protected static final String ALARM_SCHEDULE = NEW_LINE + "# Alarm Schedule" + NEW_LINE + - "Alarm Schedule JSON object represents the time interval during which the alarm rule is active. Note, " + - NEW_LINE + DEVICE_PROFILE_ALARM_SCHEDULE_ALWAYS_EXAMPLE + NEW_LINE + "means alarm rule is active all the time. " + - "**'daysOfWeek'** field represents Monday as 1, Tuesday as 2 and so on. **'startsOn'** and **'endsOn'** fields represent hours in millis (e.g. 64800000 = 18:00 or 6pm). " + - "**'enabled'** flag specifies if item in a custom rule is active for specific day of the week:" + NEW_LINE + - "## Specific Time Schedule" + NEW_LINE + - DEVICE_PROFILE_ALARM_SCHEDULE_SPECIFIC_TIME_EXAMPLE + NEW_LINE + - "## Custom Schedule" + - NEW_LINE + DEVICE_PROFILE_ALARM_SCHEDULE_CUSTOM_EXAMPLE + NEW_LINE; - - protected static final String ALARM_CONDITION_TYPE = "# Alarm condition type (**'spec'**)" + NEW_LINE + - "Alarm condition type can be either simple, duration, or repeating. For example, 5 times in a row or during 5 minutes." + NEW_LINE + - "Note, **'userValue'** field is not used and reserved for future usage, **'dynamicValue'** is used for condition appliance by using the value of the **'sourceAttribute'** " + - "or else **'defaultValue'** is used (if **'sourceAttribute'** is absent).\n" + - "\n**'sourceType'** of the **'sourceAttribute'** can be: \n" + - " * 'CURRENT_DEVICE';\n" + - " * 'CURRENT_CUSTOMER';\n" + - " * 'CURRENT_TENANT'." + NEW_LINE + - "**'sourceAttribute'** can be inherited from the owner if **'inherit'** is set to true (for CURRENT_DEVICE and CURRENT_CUSTOMER)." + NEW_LINE + - "## Repeating alarm condition" + NEW_LINE + - DEVICE_PROFILE_ALARM_CONDITION_REPEATING_EXAMPLE + NEW_LINE + - "## Duration alarm condition" + NEW_LINE + - DEVICE_PROFILE_ALARM_CONDITION_DURATION_EXAMPLE + NEW_LINE + - "**'unit'** can be: \n" + - " * 'SECONDS';\n" + - " * 'MINUTES';\n" + - " * 'HOURS';\n" + - " * 'DAYS'." + NEW_LINE; - protected static final String PROVISION_CONFIGURATION = "# Provision Configuration" + NEW_LINE + "There are 3 types of device provision configuration for the device profile: \n" + " * 'DISABLED';\n" + @@ -1618,8 +1274,8 @@ public class ControllerConstants { " * 'CHECK_PRE_PROVISIONED_DEVICES'." + NEW_LINE + "Please refer to the [docs](https://thingsboard.io/docs/user-guide/device-provisioning/) for more details." + NEW_LINE; - protected static final String DEVICE_PROFILE_DATA = DEVICE_PROFILE_DATA_DEFINITION + ALARM_SCHEDULE + ALARM_CONDITION_TYPE + - KEY_FILTERS_DESCRIPTION + PROVISION_CONFIGURATION + TRANSPORT_CONFIGURATION; + protected static final String DEVICE_PROFILE_DATA = DEVICE_PROFILE_DATA_DEFINITION + + PROVISION_CONFIGURATION + TRANSPORT_CONFIGURATION; protected static final String DEVICE_PROFILE_ID = "deviceProfileId"; diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 0aa74607be..9f59044356 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -151,6 +151,8 @@ public class DashboardController extends BaseController { @ApiOperation(value = "Get Dashboard (getDashboardById)", notes = "Get the dashboard based on 'dashboardId' parameter. " + DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH ) + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = Dashboard.class))) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/{dashboardId}") public void getDashboardById(@Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @@ -415,6 +417,8 @@ public class DashboardController extends BaseController { "If 'homeDashboardId' parameter is not set on the User level and the User has authority 'CUSTOMER_USER', check the same parameter for the corresponding Customer. " + "If 'homeDashboardId' parameter is not set on the User and Customer levels then checks the same parameter for the Tenant that owns the user. " + DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = HomeDashboard.class))) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/home") public void getHomeDashboard(@RequestHeader(name = HttpHeaders.ACCEPT_ENCODING, required = false) String acceptEncodingHeader, diff --git a/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java b/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java index 08ef1a85c8..5e555cbcbe 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java @@ -16,6 +16,8 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -50,7 +52,8 @@ public class RpcV1Controller extends AbstractRpcController { public DeferredResult handleOneWayDeviceRPCRequestV1( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) @PathVariable("deviceId") String deviceIdStr, - @Parameter(description = "A JSON value representing the RPC request.") + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.", + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleDeviceRPCRequest(true, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.REQUEST_TIMEOUT, HttpStatus.CONFLICT); } @@ -62,7 +65,8 @@ public class RpcV1Controller extends AbstractRpcController { public DeferredResult handleTwoWayDeviceRPCRequestV1( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) @PathVariable("deviceId") String deviceIdStr, - @Parameter(description = "A JSON value representing the RPC request.") + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.", + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleDeviceRPCRequest(false, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.REQUEST_TIMEOUT, HttpStatus.CONFLICT); } diff --git a/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java index fdcb853343..4366134927 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.FutureCallback; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -127,7 +128,8 @@ public class RpcV2Controller extends AbstractRpcController { public DeferredResult handleOneWayDeviceRPCRequestV2( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) @PathVariable("deviceId") String deviceIdStr, - @Parameter(description = "A JSON value representing the RPC request.") + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.", + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleDeviceRPCRequest(true, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.GATEWAY_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); } @@ -146,7 +148,8 @@ public class RpcV2Controller extends AbstractRpcController { public DeferredResult handleTwoWayDeviceRPCRequestV2( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) @PathVariable(DEVICE_ID) String deviceIdStr, - @Parameter(description = "A JSON value representing the RPC request.") + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.", + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleDeviceRPCRequest(false, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.GATEWAY_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); } diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java index ec39bb97aa..d63e0f5d19 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java @@ -17,6 +17,8 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.FutureCallback; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -87,7 +89,8 @@ public class RuleEngineController extends BaseController { @RequestMapping(value = "/", method = RequestMethod.POST) @ResponseBody public DeferredResult handleRuleEngineRequestForUser( - @Parameter(description = "A JSON value representing the message.", required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleRuleEngineRequestForEntityWithQueueAndTimeout(null, null, null, defaultResponseTimeout, requestBody); } @@ -106,7 +109,8 @@ public class RuleEngineController extends BaseController { @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = "A JSON value representing the message.", required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, defaultResponseTimeout, requestBody); } @@ -127,7 +131,8 @@ public class RuleEngineController extends BaseController { @PathVariable("entityId") String entityIdStr, @Parameter(description = "Timeout to process the request in milliseconds", required = true) @PathVariable("timeout") int timeout, - @Parameter(description = "A JSON value representing the message.", required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, timeout, requestBody); } @@ -151,7 +156,8 @@ public class RuleEngineController extends BaseController { @PathVariable("queueName") String queueName, @Parameter(description = "Timeout to process the request in milliseconds", required = true) @PathVariable("timeout") int timeout, - @Parameter(description = "A JSON value representing the message.", required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { try { SecurityUser currentUser = getCurrentUser(); diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index dc66e4a7b6..754c9253d9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -386,7 +386,8 @@ public class TelemetryController extends BaseController { @PathVariable("deviceId") String deviceIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); return saveAttributes(getTenantId(), entityId, scope, request); @@ -411,7 +412,8 @@ public class TelemetryController extends BaseController { @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"})) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); @@ -436,7 +438,8 @@ public class TelemetryController extends BaseController { @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); @@ -460,7 +463,8 @@ public class TelemetryController extends BaseController { @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 = TELEMETRY_SCOPE_DESCRIPTION, required = true, schema = @Schema(allowableValues = "ANY")) @PathVariable("scope") String scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody String requestBody) throws ThingsboardException { + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveTelemetry(getTenantId(), entityId, requestBody, 0L); } @@ -484,7 +488,8 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = TELEMETRY_SCOPE_DESCRIPTION, required = true, schema = @Schema(allowableValues = "ANY")) @PathVariable("scope") String scope, @Parameter(description = "A long value representing TTL (Time to Live) parameter.", required = true) @PathVariable("ttl") Long ttl, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody String requestBody) throws ThingsboardException { + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveTelemetry(getTenantId(), entityId, requestBody, ttl); } diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java index 48d3a907bc..cf56d051ca 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -118,7 +118,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke private final ConcurrentMap> tenantSessionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> customerSessionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> regularUserSessionsMap = new ConcurrentHashMap<>(); - private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>(); + private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>(); private Cache pendingSessions; @@ -631,7 +631,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { limitAllowed = publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerPublicUser(); if (limitAllowed) { @@ -675,7 +675,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } } if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { publicUserSessions.remove(sessionId); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java index 3ea6925695..6e9da33d0b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java @@ -61,7 +61,7 @@ public interface CalculatedFieldCache { void addOwnerEntity(TenantId tenantId, EntityId entityId); - void evictEntity(EntityId entityId); + void evictOwnerEntity(EntityId entityId); void evictOwner(EntityId owner); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 8a7d38970f..3a989aaa60 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -20,6 +20,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.server.actors.ActorSystemContext; @@ -37,6 +38,8 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.queue.util.AfterStartUp; @@ -45,7 +48,9 @@ import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -53,6 +58,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; +import java.util.stream.Collectors; import java.util.stream.Stream; @Service @@ -268,18 +274,25 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @Override public void updateOwnerEntity(TenantId tenantId, EntityId entityId) { - evictEntity(entityId); + evictOwnerEntity(entityId); addOwnerEntity(tenantId, entityId); } @Override - public void evictEntity(EntityId entityId) { + public void evictOwnerEntity(EntityId entityId) { ownerEntities.values().forEach(entities -> entities.remove(entityId)); } @Override public void evictOwner(EntityId owner) { - ownerEntities.remove(owner); + Set removedEntities = ownerEntities.remove(owner); + if (removedEntities != null) { + Set removedCustomers = removedEntities + .stream() + .filter(entityId -> entityId.getEntityType() == EntityType.CUSTOMER) + .collect(Collectors.toSet()); + removedCustomers.forEach(this::evictOwner); + } } private Set getOwnedEntities(TenantId tenantId, EntityId ownerId) { @@ -290,6 +303,113 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { }); } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT_PROFILE: + if (event.getEvent() == ComponentLifecycleEvent.UPDATED) { + TenantProfileId tenantProfileId = new TenantProfileId(event.getEntityId().getId()); + handleTenantProfileUpdate(tenantProfileId); + } + break; + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + evictTenantCfs(tenantId); + evictOwner(tenantId); + } + break; + case CUSTOMER: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) { + updateOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictOwner(event.getEntityId()); + evictOwnerEntity(event.getEntityId()); + } + break; + case DEVICE, ASSET: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) { + updateOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictOwnerEntity(event.getEntityId()); + evictEntityCfs(event.getEntityId()); + } + break; + case DEVICE_PROFILE, ASSET_PROFILE: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictEntityCfs(event.getEntityId()); + } + break; + case CALCULATED_FIELD: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED) { + updateCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else { + evict((CalculatedFieldId) event.getEntityId()); + } + break; + } + } + + private void evictTenantCfs(TenantId tenantId) { + var removedCfEntityIds = new HashSet(); + var removedLinkEntityIds = new HashSet(); + var toRemove = calculatedFields.entrySet().stream() + .filter(e -> e.getValue().getTenantId().equals(tenantId)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + toRemove.forEach(cfId -> { + CalculatedField cf = calculatedFields.remove(cfId); + List links = calculatedFieldLinks.remove(cfId); + if (links != null) { + links.forEach(link -> removedLinkEntityIds.add(link.entityId())); + } + calculatedFieldsCtx.remove(cfId); + if (cf != null) { + removedCfEntityIds.add(cf.getEntityId()); + } + }); + removedCfEntityIds.forEach(entityId -> { + entityIdCalculatedFields.compute(entityId, (k, cfs) -> { + if (cfs != null) { + cfs.removeIf(cf -> toRemove.contains(cf.getId())); + return cfs.isEmpty() ? null : cfs; + } + return null; + }); + }); + removedLinkEntityIds.forEach(entityId -> { + entityIdCalculatedFieldLinks.compute(entityId, ((entityId1, links) -> { + if (links != null) { + links.removeIf(link -> toRemove.contains(link.calculatedFieldId())); + return links.isEmpty() ? null : links; + } + return null; + })); + }); + } + + private void evictEntityCfs(EntityId entityId) { + List cfs = entityIdCalculatedFields.remove(entityId); + if (cfs != null) { + var cfIds = new HashSet(); + cfs.forEach(cf -> { + calculatedFields.remove(cf.getId()); + calculatedFieldLinks.remove(cf.getId()); + calculatedFieldsCtx.remove(cf.getId()); + cfIds.add(cf.getId()); + log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); + }); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.calculatedFieldId()))); + } + entityIdCalculatedFieldLinks.remove(entityId); + } + private Lock getFetchLock(CalculatedFieldId id) { return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock()); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index 373480e161..1a0b609c5a 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -305,7 +305,8 @@ public class CalculatedFieldCtx implements Closeable { public void setTenantProfileProperties() { TenantProfile tenantProfile = systemContext.getTenantProfileCache().get(tenantId); if (tenantProfile == null) { - throw new IllegalStateException("Tenant Profile not found for tenant: " + tenantId); + log.warn("[{}][{}][{}] Tenant Profile not found for tenant: {}. CF limits and thresholds will not be updated.", tenantId, entityId, cfId, tenantId); + return; } tenantProfile.getProfileConfiguration().ifPresent(config -> { this.maxStateSize = config.getMaxStateSizeInKBytes() * 1024L; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java index f175c46706..4adb97f380 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java @@ -15,10 +15,22 @@ */ package org.thingsboard.server.service.entitiy.cf; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfCtx; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; +import org.thingsboard.script.api.tbel.TbelCfTsDoubleVal; +import org.thingsboard.script.api.tbel.TbelCfTsRollingArg; +import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; @@ -31,10 +43,16 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; import org.thingsboard.server.service.security.model.SecurityUser; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.concurrent.TimeUnit; @TbCoreComponent @Service @@ -42,8 +60,13 @@ import java.util.Set; @RequiredArgsConstructor public class DefaultTbCalculatedFieldService extends AbstractTbEntityService implements TbCalculatedFieldService { + private static final int TIMEOUT = 20; + private final CalculatedFieldService calculatedFieldService; + @Autowired(required = false) + private TbelInvokeService tbelInvokeService; + @Override public CalculatedField save(CalculatedField calculatedField, SecurityUser user) throws ThingsboardException { ActionType actionType = calculatedField.getId() == null ? ActionType.ADDED : ActionType.UPDATED; @@ -89,6 +112,75 @@ public class DefaultTbCalculatedFieldService extends AbstractTbEntityService imp } } + @Override + public JsonNode executeTestScript(TenantId tenantId, JsonNode inputParams) { + String expression = inputParams.get("expression").asText(); + Map arguments = Objects.requireNonNullElse( + JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() {}), + Collections.emptyMap() + ); + + ArrayList ctxAndArgNames = new ArrayList<>(arguments.size() + 1); + ctxAndArgNames.add("ctx"); + ctxAndArgNames.addAll(arguments.keySet()); + + String output = ""; + String errorText = ""; + + CalculatedFieldTbelScriptEngine engine = null; + try { + if (tbelInvokeService == null) { + throw new IllegalArgumentException("TBEL script engine is disabled!"); + } + + engine = new CalculatedFieldTbelScriptEngine( + tenantId, + tbelInvokeService, + expression, + ctxAndArgNames.toArray(String[]::new) + ); + + Object[] args = new Object[ctxAndArgNames.size()]; + args[0] = new TbelCfCtx(arguments, getLatestTimestamp(arguments)); + for (int i = 1; i < ctxAndArgNames.size(); i++) { + var arg = arguments.get(ctxAndArgNames.get(i)); + if (arg instanceof TbelCfSingleValueArg svArg) { + args[i] = svArg.getValue(); + } else { + args[i] = arg; + } + } + + JsonNode json = engine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); + output = JacksonUtil.toString(json); + } catch (Exception e) { + log.error("Error evaluating expression", e); + Throwable rootCause = ObjectUtils.firstNonNull(ExceptionUtils.getRootCause(e), e); + errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getClass().getSimpleName()); + } finally { + if (engine != null) { + engine.destroy(); + } + } + return JacksonUtil.newObjectNode() + .put("output", output) + .put("error", errorText); + } + + private static long getLatestTimestamp(Map arguments) { + long lastUpdateTimestamp = -1; + for (TbelCfArg entry : arguments.values()) { + if (entry instanceof TbelCfSingleValueArg singleValueArg) { + long ts = singleValueArg.getTs(); + lastUpdateTimestamp = Math.max(lastUpdateTimestamp, ts); + } else if (entry instanceof TbelCfTsRollingArg tsRollingArg) { + long maxTs = tsRollingArg.getValues().stream().mapToLong(TbelCfTsDoubleVal::getTs).max().orElse(-1); + lastUpdateTimestamp = Math.max(lastUpdateTimestamp, maxTs); + } + } + return lastUpdateTimestamp == -1 ? System.currentTimeMillis() : lastUpdateTimestamp; + } + private void checkForEntityChange(CalculatedField oldCalculatedField, CalculatedField newCalculatedField) { if (!oldCalculatedField.getEntityId().equals(newCalculatedField.getEntityId())) { throw new IllegalArgumentException("Changing the calculated field target entity after initialization is prohibited."); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java index 5fbb96e636..9dbb20dc2f 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy.cf; +import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -35,4 +36,6 @@ public interface TbCalculatedFieldService { void delete(CalculatedField calculatedField, SecurityUser user); + JsonNode executeTestScript(TenantId tenantId, JsonNode inputParams); + } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index 28fa68d803..e0a9917509 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -23,15 +24,19 @@ 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.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -143,6 +148,25 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + Set toRemove = assetProfilesMap.values().stream() + .filter(assetProfile -> assetProfile.getTenantId().equals(tenantId)) + .map(AssetProfile::getId) + .collect(Collectors.toSet()); + assetProfilesMap.keySet().removeAll(toRemove); + assetsMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); + profileListeners.remove(tenantId); + assetProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(AssetProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 6b356adf94..4729a8c118 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -23,15 +24,19 @@ 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.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -143,6 +148,25 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + Set toRemove = deviceProfilesMap.values().stream() + .filter(deviceProfile -> deviceProfile.getTenantId().equals(tenantId)) + .map(DeviceProfile::getId) + .collect(Collectors.toSet()); + deviceProfilesMap.keySet().removeAll(toRemove); + devicesMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); + profileListeners.remove(tenantId); + deviceProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(DeviceProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index 24fa3aafb3..8a3f05f0b1 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -51,7 +51,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; import org.thingsboard.server.queue.util.TbRuleEngineComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.cf.CalculatedFieldStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; @@ -91,9 +90,8 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa PartitionService partitionService, ApplicationEventPublisher eventPublisher, JwtSettingsService jwtSettingsService, - CalculatedFieldCache calculatedFieldCache, CalculatedFieldStateService stateService) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.queueFactory = tbQueueFactory; this.stateService = stateService; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 9bf2d027cb..519601c30a 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -86,7 +86,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbCoreQueueFactory; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.notification.NotificationSchedulerService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; @@ -179,9 +178,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService consumer = requests.remove(requestId); if (consumer != null) { - consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), restApiCallResponseMsg.getResponse(), TbMsgCallback.EMPTY)); + consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), TbMsgCallback.EMPTY)); } else { log.trace("[{}] Unknown or stale rest api call response received", requestId); } diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java index d80961923d..256b230b5a 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java +++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java @@ -40,6 +40,7 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -48,7 +49,7 @@ import java.util.stream.Stream; /** * Runs at application startup and applies no-downtime data updates - * when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1). + * when the package version increases within the same LTS family (e.g., 4.3.0.0 -> 4.3.1.0 or 4.3.0.0 -> 4.3.0.1). */ @Slf4j @Component @@ -91,6 +92,8 @@ public class SystemPatchApplier { } try { + updateLtsSqlSchema(); + updateSqlViews(); log.info("Updated sql database views"); @@ -119,17 +122,37 @@ public class SystemPatchApplier { return false; } - if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) { + if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) { return false; } - log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); + log.info("Version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); return true; } - private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) { - return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor - && packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch; + private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) { + if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) { + return false; + } + if (packageVersion.maintenance != dbVersion.maintenance) { + return packageVersion.maintenance > dbVersion.maintenance; + } + return packageVersion.patch > dbVersion.patch; + } + + private void updateLtsSqlSchema() { + Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql"); + if (!Files.exists(sqlFile)) { + log.trace("LTS schema update file does not exist: {}", sqlFile); + return; + } + try { + String sql = Files.readString(sqlFile); + jdbcTemplate.execute(sql); + log.info("Applied LTS SQL schema update from {}", sqlFile); + } catch (IOException e) { + throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e); + } } private void updateSqlViews() { diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index 4b1a81dd2d..27a8ca275c 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -144,7 +144,7 @@ public class DefaultWebSocketService implements WebSocketService { private final ConcurrentMap> tenantSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> customerSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> regularUserSubscriptionsMap = new ConcurrentHashMap<>(); - private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); + private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> sessionCmdMap = new ConcurrentHashMap<>(); private ExecutorService executor; @@ -315,7 +315,7 @@ public class DefaultWebSocketService implements WebSocketService { } } - private void processSessionClose(WebSocketSessionRef sessionRef) { + void processSessionClose(WebSocketSessionRef sessionRef) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration != null) { String sessionId = "[" + sessionRef.getSessionId() + "]"; @@ -340,7 +340,7 @@ public class DefaultWebSocketService implements WebSocketService { } } if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { publicUserSessions.removeIf(subId -> subId.startsWith(sessionId)); } @@ -349,7 +349,7 @@ public class DefaultWebSocketService implements WebSocketService { } } - private boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) { + boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration == null) return true; @@ -401,9 +401,11 @@ public class DefaultWebSocketService implements WebSocketService { } } if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { - if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { + if (cmd.isUnsubscribe()) { + publicUserSessions.remove(subId); + } else if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { publicUserSessions.add(subId); } else { log.info("[{}][{}][{}] Failed to start subscription. Max public user subscriptions limit reached" diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index f21cd5c7dd..2724b9a3ff 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -598,6 +598,18 @@ actors: # Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24). # Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp" ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}" + http_client: + # Server-level ceiling for parallel in-flight HTTP requests per external HTTP rule node instance. + # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction (node config wins). + max_parallel_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PARALLEL_REQUESTS:0}" + # Server-level ceiling for the pending-request queue depth per external HTTP rule node instance. + # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction. + max_pending_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PENDING_REQUESTS:0}" + # Maximum number of TCP connections in the reactor-netty connection pool per external HTTP rule node instance. + # Defaults to reactor-netty's ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS: max(availableProcessors, 8) * 2 + # (e.g. 16 on an 8-core host). Increase for high-throughput nodes calling remote services that support many connections. + # 0 = use reactor-netty default. + pool_max_connections: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_POOL_MAX_CONNECTIONS:${TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS:0}}" rpc: # Maximum number of persistent RPC call retries in case of failed request delivery. max_retries: "${ACTORS_RPC_MAX_RETRIES:5}" diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java index 5824b832df..366089a04e 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -48,8 +48,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.expression.predic import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate.StringOperation; import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; import org.thingsboard.server.common.data.alarm.rule.condition.schedule.SpecificTimeSchedule; -import org.thingsboard.server.common.data.cf.CalculatedField; -import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.AlarmRuleDefinition; 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; @@ -128,32 +127,32 @@ public class AlarmRulesTest extends AbstractControllerTest { ); Condition clearRule = new Condition("return temperature <= 25;", null, null); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, clearRule); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); postTelemetry(deviceId, "{\"temperature\":100}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isSeverityUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); postTelemetry(deviceId, "{\"temperature\":101}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); postTelemetry(deviceId, "{\"temperature\":20}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCleared()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); @@ -185,11 +184,11 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition(simpleExpression, null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":100}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -209,11 +208,11 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"values\": {\"temperature\": 50}, \"ts\": " + (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30) + "}")); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -236,15 +235,15 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); for (int i = 0; i < 4; i++) { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(10); } - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -255,12 +254,12 @@ public class AlarmRulesTest extends AbstractControllerTest { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(10); } - checkAlarmResult(calculatedField, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> { assertThat(alarmResult.isUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); }); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isSeverityUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -288,14 +287,14 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue<>(null, "eventsCount"), null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"eventsCount\":" + eventsCount + "}"); for (int i = 0; i < eventsCount; i++) { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(10); } - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -319,13 +318,13 @@ public class AlarmRulesTest extends AbstractControllerTest { ); Condition clearRule = new Condition("return powerConsumption < 3000;", null, clearDurationMs); - CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 5 seconds", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High power consumption during 5 seconds", arguments, createRules, clearRule); postTelemetry(deviceId, "{\"powerConsumption\":3500}"); Thread.sleep(createDurationMs - 2000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -334,9 +333,9 @@ public class AlarmRulesTest extends AbstractControllerTest { postTelemetry(deviceId, "{\"powerConsumption\":2000}"); Thread.sleep(clearDurationMs - 2000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCleared()).isTrue(); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); assertThat(alarmResult.getConditionDuration()).isBetween(clearDurationMs, clearDurationMs + 2000); @@ -363,12 +362,12 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue(null, "duration"), null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High power consumption during 2 seconds", arguments, createRules, null); postTelemetry(deviceId, "{\"powerConsumption\":3500}"); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"duration\":" + createDurationMs + "}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -391,10 +390,10 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue(2000L, null), null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High power consumption during 2 seconds", arguments, createRules, null); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -424,12 +423,12 @@ public class AlarmRulesTest extends AbstractControllerTest { device.setCustomerId(customerId); device = doPost("/api/device", device, Device.class); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}"); postTelemetry(deviceId, "{\"temperature\":51}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -462,21 +461,21 @@ public class AlarmRulesTest extends AbstractControllerTest { "location", StringOperation.NOT_CONTAINS, new AlarmConditionValue<>(null, "locationFilter") ), null, null); - CalculatedField calculatedField = createAlarmCf(customerId, "New resident", + AlarmRuleDefinition alarmRule = createAlarmRule(customerId, "New resident", arguments, createRules, clearRule); loginSysAdmin(); postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"locationFilter\":\"Kyiv\"}"); loginTenantAdmin(); postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Kyiv\"}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Lviv\"}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCleared()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); @@ -501,7 +500,7 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue<>(null, "schedule")) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); String schedule = """ {"timezone":"Europe/Kiev","items":[{"enabled":false,"dayOfWeek":1,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":2,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":3,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":4,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":5,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":6,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":7,"startsOn":0,"endsOn":0}]} @@ -510,14 +509,14 @@ public class AlarmRulesTest extends AbstractControllerTest { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(1000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); schedule = schedule.replace("\"enabled\":false", "\"enabled\":true"); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}"); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { // checking multiple debug events due to scheduled reevaluation (which also produces debug events) - CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() + CalculatedFieldDebugEvent debugEvent = getDebugEvents(alarmRule.getId(), 5).stream() .filter(event -> event.getResult() != null) .findFirst().orElse(null); assertThat(debugEvent).isNotNull(); @@ -565,18 +564,18 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition(criticalExpression, null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "No Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "No Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isSeverityUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -596,19 +595,19 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); - calculatedField.setName("New alarm type"); - calculatedField = saveCalculatedField(calculatedField); - checkAlarmResult(calculatedField, alarmResult -> { + alarmRule.setName("New alarm type"); + alarmRule = saveAlarmRule(alarmRule); + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -628,18 +627,18 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(1000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); - AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration(); ((TbelAlarmConditionExpression) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition().getExpression()) .setExpression("return temperature >= 50;"); - calculatedField = saveCalculatedField(calculatedField); - checkAlarmResult(calculatedField, alarmResult -> { + alarmRule = saveAlarmRule(alarmRule); + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -662,13 +661,13 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); for (int i = 0; i < eventsCountMajor; i++) { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(10); } - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -676,18 +675,18 @@ public class AlarmRulesTest extends AbstractControllerTest { }); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getConditionRepeats()).isEqualTo(6); }); // decreasing required events count for critical rule - AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration(); ((RepeatingAlarmCondition) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition()) .setCount(new AlarmConditionValue<>(6, null)); - calculatedField = saveCalculatedField(calculatedField); - checkAlarmResult(calculatedField, alarmResult -> { + alarmRule = saveAlarmRule(alarmRule); + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isSeverityUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -719,20 +718,20 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(1000); // not created because tenant's threshold 100 is used - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); - ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getArguments().get("temperatureThreshold") + ((AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration()).getArguments().get("temperatureThreshold") .setRefDynamicSourceConfiguration(null); // using threshold 50 on device level - calculatedField = saveCalculatedField(calculatedField); + alarmRule = saveAlarmRule(alarmRule); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -755,7 +754,7 @@ public class AlarmRulesTest extends AbstractControllerTest { Map createRules = Map.of( AlarmSeverity.CRITICAL, new Condition("return temperature >= 50 && humidity >= 50;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature and Humidity Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature and Humidity Alarm", arguments, createRules, null, configuration -> { configuration.getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( "temperature is ${temperature}, humidity is ${humidity}" @@ -765,18 +764,18 @@ public class AlarmRulesTest extends AbstractControllerTest { postTelemetry(deviceId, "{\"temperature\":50}"); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"humidity\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getDetails().get("data").asText()) .isEqualTo("temperature is 50, humidity is 50"); }); - ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( + ((AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration()).getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( "UPDATED temperature is ${temperature}, humidity is ${humidity}" ); - calculatedField = saveCalculatedField(calculatedField); - checkAlarmResult(calculatedField, alarmResult -> { + alarmRule = saveAlarmRule(alarmRule); + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isFalse(); assertThat(alarmResult.isUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); @@ -805,16 +804,16 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue<>(schedule, null)) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "Illegal parking alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "Illegal parking alarm", arguments, createRules, null); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"parkingSpotOccupied\":true}"); Thread.sleep(10000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { - CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() + CalculatedFieldDebugEvent debugEvent = getDebugEvents(alarmRule.getId(), 5).stream() .filter(event -> event.getResult() != null) .findFirst().orElse(null); assertThat(debugEvent).isNotNull(); @@ -838,11 +837,11 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); - Alarm alarm = checkAlarmResult(calculatedField, alarmResult -> { + Alarm alarm = checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -851,7 +850,7 @@ public class AlarmRulesTest extends AbstractControllerTest { doPost("/api/alarm/" + alarm.getId() + "/clear", AlarmInfo.class); Thread.sleep(1000); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.getAlarm().getId()).isNotEqualTo(alarm.getId()); assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); @@ -861,21 +860,21 @@ public class AlarmRulesTest extends AbstractControllerTest { // TODO: MSA tests - private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, Consumer assertion) { - return checkAlarmResult(calculatedField, null, assertion); + private TbAlarmResult checkAlarmResult(AlarmRuleDefinition alarmRule, Consumer assertion) { + return checkAlarmResult(alarmRule, null, assertion); } - private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, + private TbAlarmResult checkAlarmResult(AlarmRuleDefinition alarmRule, Predicate waitFor, Consumer assertion) { TbAlarmResult alarmResult = await().atMost(TIMEOUT, TimeUnit.SECONDS) - .until(() -> getLatestAlarmResult(calculatedField.getId()), result -> + .until(() -> getLatestAlarmResult(alarmRule.getId()), result -> result != null && (waitFor == null || waitFor.test(result))); assertion.accept(alarmResult); Alarm alarm = alarmResult.getAlarm(); assertThat(alarm.getOriginator()).isEqualTo(originatorId); - assertThat(alarm.getType()).isEqualTo(calculatedField.getName()); + assertThat(alarm.getType()).isEqualTo(alarmRule.getName()); return alarmResult; } @@ -895,38 +894,37 @@ public class AlarmRulesTest extends AbstractControllerTest { return JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); } - private CalculatedField createAlarmCf(EntityId entityId, - String alarmType, - Map arguments, - Map createConditions, - Condition clearCondition, - Consumer... modifier) { + private AlarmRuleDefinition createAlarmRule(EntityId entityId, + String alarmType, + Map arguments, + Map createConditions, + Condition clearCondition, + Consumer... modifier) { Map createRules = new HashMap<>(); createConditions.forEach((severity, condition) -> { createRules.put(severity, toAlarmRule(condition)); }); AlarmRule clearRule = clearCondition != null ? toAlarmRule(clearCondition) : null; - CalculatedField calculatedField = new CalculatedField(); - calculatedField.setEntityId(entityId); - calculatedField.setName(alarmType); - calculatedField.setType(CalculatedFieldType.ALARM); + AlarmRuleDefinition alarmRule = new AlarmRuleDefinition(); + alarmRule.setEntityId(entityId); + alarmRule.setName(alarmType); AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); configuration.setArguments(arguments); configuration.setCreateRules(createRules); configuration.setClearRule(clearRule); - calculatedField.setConfiguration(configuration); - calculatedField.setDebugSettings(DebugSettings.all()); + alarmRule.setConfiguration(configuration); + alarmRule.setDebugSettings(DebugSettings.all()); if (modifier.length > 0) { modifier[0].accept(configuration); } - CalculatedField savedCalculatedField = saveCalculatedField(calculatedField); + AlarmRuleDefinition savedAlarmRule = saveAlarmRule(alarmRule); CalculatedFieldDebugEvent debugEvent = await().atMost(TIMEOUT, TimeUnit.SECONDS) - .until(() -> getDebugEvents(savedCalculatedField.getId(), 1), + .until(() -> getDebugEvents(savedAlarmRule.getId(), 1), events -> !events.isEmpty()).get(0); latestEventId = debugEvent.getId(); - return savedCalculatedField; + return savedAlarmRule; } private AlarmRule toAlarmRule(Condition condition) { diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 6a658ffd66..055e1e19a0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -94,6 +94,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.AlarmRuleDefinition; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @@ -1482,6 +1483,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return doPost("/api/calculatedField", calculatedField, CalculatedField.class); } + protected AlarmRuleDefinition saveAlarmRule(AlarmRuleDefinition alarmRule) { + return doPost("/api/alarm/rule", alarmRule, AlarmRuleDefinition.class); + } + protected PageData getEntityCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception { return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" + (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 17a30cac35..3bb5fd5647 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -44,7 +44,9 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.LinkedList; import java.util.List; +import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -226,6 +228,18 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) .andExpect(status().isOk()); + Optional systemCommentOpt = doGetTyped( + "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() { + } + ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst(); + assertThat(systemCommentOpt).isPresent(); + AlarmCommentInfo systemComment = systemCommentOpt.get(); + + assertThat(systemComment.getId()).isEqualTo(alarmComment.getId()); + assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM); + assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment", + TENANT_ADMIN_EMAIL)); + AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) @@ -361,6 +375,39 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { Assert.assertTrue("Created alarm doesn't match the found one!", equals); } + @Test + public void testShouldNotCreateOrUpdateSystemAlarmComment() throws Exception { + loginTenantAdmin(); + + AlarmComment alarmComment = AlarmComment.builder() + .type(AlarmCommentType.SYSTEM) + .comment(JacksonUtil.newObjectNode().set("text", new TextNode("Acknowledged by tenant admin"))) + .build(); + AlarmComment created = doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment, AlarmComment.class); + assertThat(created.getType()).isEqualTo(AlarmCommentType.OTHER); + + // acknowledge alarm to create system comment + doPost("/api/alarm/" + alarm.getId() + "/ack").andExpect(status().isOk()); + + Optional systemCommentOpt = doGetTyped( + "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() { + } + ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst(); + assertThat(systemCommentOpt).isPresent(); + AlarmCommentInfo systemComment = systemCommentOpt.get(); + + // system comment can't be updated with other type + systemComment.setType(AlarmCommentType.OTHER); + doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("System alarm comment can't be updated!"))); + + // system comment can't be updated with other text + systemComment.setType(AlarmCommentType.SYSTEM); + systemComment.setComment(JacksonUtil.newObjectNode().set("text", new TextNode("New system comment"))); + doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("System alarm comment can't be updated!"))); + } + private AlarmComment createAlarmComment(AlarmId alarmId, String text) { AlarmComment alarmComment = AlarmComment.builder() .comment(JacksonUtil.newObjectNode().set("text", new TextNode(text))) diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmRuleControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmRuleControllerTest.java new file mode 100644 index 0000000000..7c64fd6525 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmRuleControllerTest.java @@ -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 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 all = getAlarmRules(null, null); + assertThat(all).extracting(AlarmRuleDefinition::getName) + .contains("Device Alarm", "Profile Alarm"); + + // Filter by entity type: DEVICE + List deviceRules = getAlarmRules(EntityType.DEVICE, null); + assertThat(deviceRules).extracting(AlarmRuleDefinition::getName) + .containsOnly("Device Alarm"); + + // Filter by entity type: DEVICE_PROFILE + List profileRules = getAlarmRules(EntityType.DEVICE_PROFILE, null); + assertThat(profileRules).extracting(AlarmRuleDefinition::getName) + .containsOnly("Profile Alarm"); + + // Filter by specific entity IDs + List 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 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 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 getAlarmRules(EntityType entityType, List 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>() {}, new PageLink(10)).getData(); + } + + private PageData getAlarmRuleNames(PageLink pageLink) throws Exception { + return doGetTypedWithPageLink("/api/alarm/rules/names?", + new TypeReference>() {}, pageLink); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java index accbbd1d29..970c244fad 100644 --- a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java @@ -27,12 +27,19 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.adapter.NativeWebSocketSession; import org.thingsboard.common.util.ThingsBoardThreadFactory; +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.tenant.TbTenantProfileCache; import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider; import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.ws.WebSocketService; import org.thingsboard.server.service.ws.WebSocketSessionRef; import org.thingsboard.server.service.ws.WebSocketSessionType; @@ -193,6 +200,72 @@ class TbWebSocketHandlerTest { assertThat(msgs).map(Integer::parseInt).doesNotHaveDuplicates().hasSize(100); } + // Regression test for the bug where publicUserSessionsMap was keyed by UserId(NULL_UUID), + // making maxWsSessionsPerPublicUser a global limit shared across all tenants. + // The limit is now scoped per-tenant. + @Test + void checkLimits_publicUserSessions_limitIsPerTenantNotGlobal() throws Exception { + TbTenantProfileCache tenantProfileCache = mock(TbTenantProfileCache.class); + ReflectionTestUtils.setField(wsHandler, "tenantProfileCache", tenantProfileCache); + + int maxPublicSessions = 2; + + TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile1 = new TenantProfile(); + profile1.createDefaultTenantProfileData(); + profile1.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions); + willReturn(profile1).given(tenantProfileCache).get(tenant1); + + TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile2 = new TenantProfile(); + profile2.createDefaultTenantProfileData(); + profile2.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions); + willReturn(profile2).given(tenantProfileCache).get(tenant2); + + Method checkLimits = TbWebSocketHandler.class.getDeclaredMethod( + "checkLimits", WebSocketSession.class, WebSocketSessionRef.class); + checkLimits.setAccessible(true); + + // tenant1 fills up its limit + for (int i = 0; i < maxPublicSessions; i++) { + assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t1-" + i), mockPublicSessionRef(tenant1))).isTrue(); + } + + // tenant2 must get its own independent quota — this was the bug: with NULL_UUID as key + // all tenants shared one global counter, so tenant2 would be blocked here + for (int i = 0; i < maxPublicSessions; i++) { + assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t2-" + i), mockPublicSessionRef(tenant2))) + .as("tenant2 session %d should not be affected by tenant1's sessions", i + 1) + .isTrue(); + } + + // tenant1's (maxPublicSessions + 1)-th session must be rejected + NativeWebSocketSession overLimit = mockWsSession("t1-over"); + assertThat((boolean) checkLimits.invoke(wsHandler, overLimit, mockPublicSessionRef(tenant1))).isFalse(); + verify(overLimit).close(CloseStatus.POLICY_VIOLATION.withReason("Max public user sessions limit reached")); + } + + private NativeWebSocketSession mockWsSession(String id) { + NativeWebSocketSession s = mock(NativeWebSocketSession.class); + willReturn(id).given(s).getId(); + return s; + } + + private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId) { + 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(UUID.randomUUID().toString()).given(ref).getSessionId(); + return ref; + } + private AuthTestFixture createAuthTestFixture() throws IOException { TbWebSocketHandler handler = spy(new TbWebSocketHandler()); willDoNothing().given(handler).close(any(), any()); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java new file mode 100644 index 0000000000..3ee229e7a0 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java @@ -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; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java new file mode 100644 index 0000000000..f9b8d428d7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java @@ -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); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java new file mode 100644 index 0000000000..a26413514c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java @@ -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); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java new file mode 100644 index 0000000000..a533e0369f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java @@ -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> publicUserSubscriptionsMap = + (ConcurrentMap>) 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> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + + Set 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> publicUserSubscriptionsMap = + (ConcurrentMap>) 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> publicUserSubscriptionsMap = + (ConcurrentMap>) 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> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3); + + service.processSessionClose(session1); + + Set 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; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java index 67e5e946c8..f3ea4e838c 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; import org.thingsboard.server.service.system.SystemPatchApplier; @@ -71,6 +72,9 @@ public class SystemPatchApplierTest { @Mock private InstallScripts installScripts; + @Mock + private DatabaseSchemaSettingsService schemaSettingsService; + @Mock private WidgetTypeService widgetTypeService; @@ -373,6 +377,247 @@ public class SystemPatchApplierTest { verify(widgetTypeService, times(1)).saveWidgetType(any()); } + // --- isVersionIncreased tests --- + + @ParameterizedTest(name = "isVersionIncreased: {0} (package={1}, db={2}) -> {3}") + @MethodSource("provideVersionComparisonTestCases") + void testIsVersionIncreased(String testName, SystemPatchApplier.VersionInfo packageVersion, + SystemPatchApplier.VersionInfo dbVersion, boolean expected) { + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionIncreased", packageVersion, dbVersion); + assertEquals(expected, result, testName); + } + + private static Stream provideVersionComparisonTestCases() { + return Stream.of( + // Maintenance digit increases within same LTS family + Arguments.of("maintenance increased", + new SystemPatchApplier.VersionInfo(4, 3, 1, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + Arguments.of("maintenance increased by more than one", + new SystemPatchApplier.VersionInfo(4, 3, 3, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + + // Patch digit increases within same maintenance + Arguments.of("patch increased", + new SystemPatchApplier.VersionInfo(4, 3, 0, 1), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + Arguments.of("patch increased by more than one", + new SystemPatchApplier.VersionInfo(4, 3, 0, 5), + new SystemPatchApplier.VersionInfo(4, 3, 0, 2), true), + + // Both maintenance and patch increased + Arguments.of("maintenance and patch both increased", + new SystemPatchApplier.VersionInfo(4, 3, 1, 1), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + + // Maintenance increased, patch value is lower (irrelevant — maintenance wins) + Arguments.of("maintenance increased, patch is lower", + new SystemPatchApplier.VersionInfo(4, 3, 2, 0), + new SystemPatchApplier.VersionInfo(4, 3, 1, 5), true), + + // Same version — no increase + Arguments.of("same version", + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + Arguments.of("same version with non-zero parts", + new SystemPatchApplier.VersionInfo(4, 3, 1, 2), + new SystemPatchApplier.VersionInfo(4, 3, 1, 2), false), + + // Decreased versions — no increase + Arguments.of("maintenance decreased", + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 1, 0), false), + Arguments.of("patch decreased", + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 1), false), + + // Different major — different family, skip + Arguments.of("different major", + new SystemPatchApplier.VersionInfo(5, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + Arguments.of("major decreased", + new SystemPatchApplier.VersionInfo(3, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + + // Different minor — different LTS family, skip + Arguments.of("minor increased (different LTS family)", + new SystemPatchApplier.VersionInfo(4, 4, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + Arguments.of("minor decreased", + new SystemPatchApplier.VersionInfo(4, 2, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false) + ); + } + + // --- isVersionChanged tests --- + + @Test + void whenVersionIncreased_thenVersionChangedReturnsTrue() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertTrue(result); + } + + @Test + void whenVersionNotIncreased_thenVersionChangedReturnsFalse() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertFalse(result); + } + + @Test + void whenVersionUnparseable_thenVersionChangedReturnsFalse() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("invalid"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertFalse(result); + } + + @Test + void whenDbVersionUnparseable_thenVersionChangedReturnsFalse() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("bad"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertFalse(result); + } + + // --- updateLtsSqlSchema tests --- + + @Test + void whenLtsSqlFileExists_thenExecutesSql() throws Exception { + Path dataDir = tempDir.resolve("data"); + Path ltsDir = dataDir.resolve("upgrade").resolve("lts"); + Files.createDirectories(ltsDir); + Files.writeString(ltsDir.resolve("schema_update.sql"), "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);"); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema"); + + verify(jdbcTemplate).execute("ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);"); + } + + @Test + void whenLtsSqlFileDoesNotExist_thenSkips() { + Path dataDir = tempDir.resolve("data"); + // Don't create the file + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema"); + + verify(jdbcTemplate, never()).execute(anyString()); + } + + @Test + void whenLtsSqlFileHasMultipleStatements_thenExecutesAll() throws Exception { + Path dataDir = tempDir.resolve("data"); + Path ltsDir = dataDir.resolve("upgrade").resolve("lts"); + Files.createDirectories(ltsDir); + String sql = "DO $$ BEGIN\n" + + " IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'test_type') THEN\n" + + " CREATE TYPE test_type AS ENUM ('A', 'B');\n" + + " END IF;\n" + + "END $$;\n" + + "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);"; + Files.writeString(ltsDir.resolve("schema_update.sql"), sql); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema"); + + verify(jdbcTemplate).execute(sql); + } + + // --- applyPatchIfNeeded flow tests --- + + @Test + void whenVersionIncreased_thenAppliesLtsSqlBeforeViewsAndWidgets() throws Exception { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true); + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true); + + Path dataDir = tempDir.resolve("data"); + Path ltsDir = dataDir.resolve("upgrade").resolve("lts"); + Files.createDirectories(ltsDir); + Files.writeString(ltsDir.resolve("schema_update.sql"), "SELECT 1;"); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + // LTS SQL was executed + verify(jdbcTemplate).execute("SELECT 1;"); + // Schema version was updated + verify(schemaSettingsService).updateSchemaVersion(); + } + + @Test + void whenVersionNotIncreased_thenSkipsEverything() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + // No lock acquired + verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()); + // No schema update + verify(schemaSettingsService, never()).updateSchemaVersion(); + } + + @Test + void whenLockNotAcquired_thenSkipsPatchApplication() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(false); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(schemaSettingsService, never()).updateSchemaVersion(); + verify(jdbcTemplate, never()).execute(anyString()); + } + + @Test + void whenMaintenanceVersionIncreased_thenAppliesPatch() throws Exception { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.2.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.1.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true); + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true); + + Path dataDir = tempDir.resolve("data"); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(schemaSettingsService).updateSchemaVersion(); + } + + @Test + void whenDifferentLtsFamily_thenSkipsPatch() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.4.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()); + verify(schemaSettingsService, never()).updateSchemaVersion(); + } + private static Stream provideDescriptorComparisonTestCases() { return Stream.of( Arguments.of("Both null", null, null, true), diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java index bf5fb7e448..3e8aef83a1 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java @@ -18,6 +18,8 @@ package org.thingsboard.server.cache; import org.springframework.data.redis.serializer.SerializationException; import org.thingsboard.common.util.JacksonUtil; +import java.io.IOException; + public class TbJsonRedisSerializer implements TbRedisSerializer { private final Class clazz; @@ -33,6 +35,13 @@ public class TbJsonRedisSerializer implements TbRedisSerializer { @Override public V deserialize(K key, byte[] bytes) throws SerializationException { - return JacksonUtil.fromBytes(bytes, clazz); + if (bytes == null) { + return null; + } + try { + return JacksonUtil.IGNORE_UNKNOWN_PROPERTIES_JSON_MAPPER.readValue(bytes, clazz); + } catch (IOException e) { + throw new SerializationException("Failed to deserialize cached value", e); + } } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java index 8931e6a270..956cf054dd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java @@ -134,7 +134,7 @@ public class Customer extends ContactBased implements HasTenantId, E return super.getPhone(); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Email", example = "example@company.com") + @Schema(description = "Email", example = "example@company.com") @Override public String getEmail() { return super.getEmail(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java index cc0b413025..707d9f1148 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java @@ -176,7 +176,7 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL this.label = label; } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with Device Profile Id.") + @Schema(description = "JSON object with Device Profile Id. If not provided, the type will be used to determine the profile. If neither deviceProfileId nor type is specified, the default device profile will be used.") public DeviceProfileId getDeviceProfileId() { return deviceProfileId; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java index 8bf65873a3..e21b8d3cd0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java @@ -22,6 +22,7 @@ import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.Value; import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; @@ -75,4 +76,10 @@ public class DeviceProfileInfo extends EntityInfo { profile.getType(), profile.getTransportType()); } + @Override + @Schema(implementation = DeviceProfileId.class, description = "JSON object with the Device Profile Id.") + public EntityId getId() { + return super.getId(); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java index ffca023b23..4d4fcfa58e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java @@ -46,7 +46,6 @@ public final class AiModel extends BaseData implements HasTenantId, H private static final long serialVersionUID = 9017108678716011604L; @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "JSON object representing the ID of the tenant associated with this AI model", example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d" @@ -54,7 +53,6 @@ public final class AiModel extends BaseData implements HasTenantId, H private TenantId tenantId; @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "Version of the AI model record; increments automatically whenever the record is changed", example = "7", diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java index c456323120..65e943c574 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java @@ -39,6 +39,7 @@ public class AlarmCreateOrUpdateActiveRequest implements AlarmModificationReques private TenantId tenantId; @Schema(description = "JSON object with Customer Id", accessMode = Schema.AccessMode.READ_ONLY) private CustomerId customerId; + @NoXss @NotNull @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "representing type of the Alarm", example = "High Temperature Alarm") @Length(fieldName = "type") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java index b7dc5059a7..3ab3f0a526 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java @@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmCondition; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.id.DashboardId; -@Schema(name = "AlarmRuleDefinition") +@Schema @Data @AllArgsConstructor @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java index 88dc7f5859..87bd44ce1f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java @@ -32,7 +32,6 @@ import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSch import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AnyTimeSchedule; @Schema( - name = "AlarmRuleCondition", discriminatorProperty = "type", discriminatorMapping = { @DiscriminatorMapping(value = "SIMPLE", schema = SimpleAlarmCondition.class), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java index bee112d25d..3599ee5c3c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java @@ -25,7 +25,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.concurrent.TimeUnit; -@Schema(name = "AlarmRuleDurationCondition") +@Schema @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java index ca03d257a1..42cba04ec0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java @@ -22,7 +22,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; -@Schema(name = "AlarmRuleRepeatingCondition") +@Schema @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java index 38e0434b1d..f91ff1f3f0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java @@ -17,7 +17,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(name = "AlarmRuleSimpleCondition") +@Schema public class SimpleAlarmCondition extends AlarmCondition { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java index d80eccb9fc..578b7909c0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java @@ -32,7 +32,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; import java.util.List; -@Schema(name = "AlarmRuleConditionFilter") +@Schema @Data public class AlarmConditionFilter implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java index 0ff72c4297..64a73d2f6d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java @@ -37,6 +37,7 @@ public class BooleanFilterPredicate implements SimpleKeyFilterPredicate return FilterPredicateType.BOOLEAN; } + @Schema(name = "AlarmRuleBooleanOperation") public enum BooleanOperation { EQUAL, NOT_EQUAL diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java index 6463aa7f81..1f719cb80c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java @@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionVal import java.util.concurrent.TimeUnit; -@Schema(name = "AlarmRuleNoDataFilterPredicate") +@Schema @Data @AllArgsConstructor @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java index 2fb249fc96..5bddbc4387 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java @@ -41,6 +41,7 @@ public class NumericFilterPredicate implements SimpleKeyFilterPredicate return FilterPredicateType.NUMERIC; } + @Schema(name = "AlarmRuleNumericOperation") public enum NumericOperation { EQUAL, NOT_EQUAL, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java index 3af7d9c083..602730b7e0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java @@ -38,6 +38,7 @@ public class StringFilterPredicate implements SimpleKeyFilterPredicate { return FilterPredicateType.STRING; } + @Schema(name= "AlarmRuleStringOperation") public enum StringOperation { EQUAL, NOT_EQUAL, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java index c77eaf4fa5..127487b471 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java @@ -27,7 +27,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; @Schema( - name = "AlarmRuleSchedule", discriminatorProperty = "type", discriminatorMapping = { @DiscriminatorMapping(value = "ANY_TIME", schema = AnyTimeSchedule.class), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java index b2655f58d5..28093fd845 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java @@ -17,7 +17,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(name = "AlarmRuleScheduleType") +@Schema public enum AlarmScheduleType { ANY_TIME, SPECIFIC_TIME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java index 4c1b9fdc75..873e1f882d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java @@ -17,7 +17,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(name = "AlarmRuleAnyTimeSchedule") +@Schema public class AnyTimeSchedule implements AlarmSchedule { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java index 1530de019b..aea9db5be4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java @@ -20,7 +20,7 @@ import lombok.Data; import java.util.List; -@Schema(name = "AlarmRuleCustomTimeSchedule") +@Schema @Data public class CustomTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java index 0029ea9e24..d0756a3195 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java @@ -21,7 +21,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; -@Schema(name = "AlarmRuleCustomTimeScheduleItem") +@Schema @Data public class CustomTimeScheduleItem implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java index 1a888a1e87..a23dbe48a7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java @@ -20,7 +20,7 @@ import lombok.Data; import java.util.Set; -@Schema(name = "AlarmRuleSpecificTimeSchedule") +@Schema @Data public class SpecificTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfileInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfileInfo.java index e5d0855989..a2d879ff0e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfileInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfileInfo.java @@ -23,6 +23,7 @@ import lombok.ToString; import lombok.Value; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -66,4 +67,10 @@ public class AssetProfileInfo extends EntityInfo { this(profile.getId(), profile.getTenantId(), profile.getName(), profile.getImage(), profile.getDefaultDashboardId()); } + @Override + @Schema(implementation = AssetProfileId.class, description = "JSON object with the Asset Profile Id.") + public EntityId getId() { + return super.getId(); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java new file mode 100644 index 0000000000..bd0c76bc05 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java @@ -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 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(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinitionInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinitionInfo.java new file mode 100644 index 0000000000..145dfc4081 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinitionInfo.java @@ -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()); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java index fc0c4fdd2c..120df6f3b2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java @@ -24,7 +24,7 @@ import lombok.Data; import java.io.Serializable; import java.util.List; -@Schema +@Schema(hidden = true) @Data @JsonIgnoreProperties(ignoreUnknown = true) @Deprecated diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java index b2a3083f6f..96dfee9703 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java @@ -24,7 +24,7 @@ import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serializable; -@Schema +@Schema(hidden = true) @Data @Deprecated public class AlarmConditionFilter implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java index 9ceea43677..7a9be0f302 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java @@ -24,7 +24,7 @@ import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serial; import java.io.Serializable; -@Schema +@Schema(hidden = true) @Data @Deprecated public class AlarmRule implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java index 5509f7e896..884d9f13c5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java @@ -18,20 +18,13 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.query.DynamicValue; import java.io.Serializable; @Schema( - description = "Configuration for alarm schedule", - discriminatorProperty = "type", - discriminatorMapping = { - @DiscriminatorMapping(value = "ANY_TIME", schema = AnyTimeSchedule.class), - @DiscriminatorMapping(value = "SPECIFIC_TIME", schema = SpecificTimeSchedule.class), - @DiscriminatorMapping(value = "CUSTOM", schema = CustomTimeSchedule.class) - } + hidden = true ) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java index 7f78dbc46d..1183f9ae63 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(hidden = true) @Deprecated public enum AlarmScheduleType { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java index e7acb04cd3..70463dfbe7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.query.DynamicValue; +@Schema(hidden = true) @Deprecated public class AnyTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java index 878a85360b..cea1d76d36 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.query.DynamicValue; import java.util.List; +@Schema(hidden = true) @Data @Deprecated public class CustomTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java index 0d4f5cf018..d91c978ac5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serializable; +@Schema(hidden = true) @Data @Deprecated public class CustomTimeScheduleItem implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java index c0a9e5646e..da7282f48a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.common.data.device.profile; -import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.Data; @@ -37,7 +36,7 @@ public class DeviceProfileData implements Serializable { @Schema(description = "JSON object of provisioning strategy type per device profile") private DeviceProfileProvisionConfiguration provisionConfiguration; @Valid - @ArraySchema(schema = @Schema(implementation = DeviceProfileAlarm.class)) + @Schema(hidden = true) private List alarms; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java index dfaf6c763c..8bd56d07be 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.query.DynamicValue; import java.util.Set; +@Schema(hidden = true) @Data @Deprecated public class SpecificTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java index 808738a78b..e882c74eb6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java @@ -46,7 +46,6 @@ public abstract class TaskResult { @JsonIgnore public abstract JobType getJobType(); - @JsonIgnore public abstract String getError(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java b/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java index 68f06d94d4..25cd734d77 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.page; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data @@ -36,6 +37,7 @@ public class SortOrder { return new SortOrder(property, direction); } + @Schema(name = "SortOrderDirection") public static enum Direction { ASC, DESC } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java index 4f0db45faa..f08646ec4e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "AlarmRuleComplexOperation") public enum ComplexOperation { AND, OR diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java index 4d8bb35919..f81bf841e5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java @@ -41,7 +41,7 @@ public class RuleChain extends BaseDataWithAdditionalInfo implement private static final long serialVersionUID = -5656679015121935465L; - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with Tenant Id.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with Tenant Id.", accessMode = Schema.AccessMode.READ_ONLY) private TenantId tenantId; @NoXss @Length(fieldName = "name") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java index 4cd0e51925..008988e6cb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java @@ -17,7 +17,7 @@ package org.thingsboard.server.common.data.transport.resource; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(name = "TransportResourceType") +@Schema(hidden = true) public enum ResourceType { LWM2M_MODEL, JKS, PKCS_12 } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java index 3955abb63f..abfbdcbe30 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java @@ -201,18 +201,6 @@ public final class TbMsg implements Serializable { return builder.build(); } - @Deprecated(forRemoval = true, since = "4.1") // to be removed in 4.2 - public static TbMsg fromProto(String queueName, TbMsgProto proto, ByteString data, TbMsgCallback callback) { - try { - if (!data.isEmpty()) { - proto = TbMsgProto.parseFrom(data); - } - } catch (InvalidProtocolBufferException e) { - throw new IllegalStateException("Could not parse protobuf for TbMsg", e); - } - return fromProto(queueName, proto, callback); - } - public static TbMsg fromProto(String queueName, TbMsgProto proto, TbMsgCallback callback) { TbMsgMetaData metaData = new TbMsgMetaData(proto.getMetaData().getDataMap()); EntityId entityId = EntityIdFactory.getByTypeAndUuid(proto.getEntityType(), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())); diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 8a44a19e17..68f7052c69 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -139,7 +139,6 @@ message SessionInfoProto { message RestApiCallResponseMsgProto { int64 requestIdMSB = 1; int64 requestIdLSB = 2; - bytes response = 5 [deprecated = true]; msgqueue.TbMsgProto responseProto = 6; } diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java index 708263b26a..843f857218 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java @@ -245,7 +245,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } List getEfentoMeasurements(MeasurementsProtos.ProtoMeasurements protoMeasurements, UUID sessionId) { - String serialNumber = CoapEfentoUtils.convertByteArrayToString(protoMeasurements.getSerialNum().toByteArray()); + String serialNumber = CoapEfentoUtils.convertByteArrayToString(protoMeasurements.getSerialNumber().toByteArray()); boolean batteryStatus = protoMeasurements.getBatteryStatus(); int measurementPeriodBase = protoMeasurements.getMeasurementPeriodBase(); int measurementPeriodFactor = protoMeasurements.getMeasurementPeriodFactor(); @@ -258,6 +258,9 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } Map valuesMap = new TreeMap<>(); + // general measurements per message + valuesMap.put(TimeUnit.SECONDS.toMillis(channelsList.get(0).getTimestamp()), CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, nextTransmissionAtMillis, signal)); + for (int channel = 0; channel < channelsList.size(); channel++) { ProtoChannel protoChannel = channelsList.get(channel); List sampleOffsetsList = protoChannel.getSampleOffsetsList(); @@ -271,6 +274,10 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { long measurementPeriodMillis = TimeUnit.SECONDS.toMillis(measurementPeriod); long startTimestampMillis = TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()); + // measurements per channel + JsonObject tsValues = valuesMap.computeIfAbsent(startTimestampMillis, k -> new JsonObject()); + tsValues.addProperty("measurement_interval_" + (channel + 1), measurementPeriod); + for (int i = 0; i < sampleOffsetsList.size(); i++) { int sampleOffset = sampleOffsetsList.get(i); if (isSensorError(sampleOffset)) { @@ -290,22 +297,16 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } long sampleOffsetMillis = TimeUnit.SECONDS.toMillis(sampleOffset); long measurementTimestamp = startTimestampMillis + Math.abs(sampleOffsetMillis); - values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> new JsonObject()); addBinarySample(protoChannel, currentIsOk, values, channel + 1, sessionId); } else { long timestampMillis = startTimestampMillis + i * measurementPeriodMillis; - values = valuesMap.computeIfAbsent(timestampMillis, k -> CoapEfentoUtils.setDefaultMeasurements( - serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + values = valuesMap.computeIfAbsent(timestampMillis, k -> new JsonObject()); addContinuesSample(protoChannel, sampleOffset, values, channel + 1, sessionId); } } } - if (CollectionUtils.isEmpty(valuesMap)) { - throw new IllegalStateException("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); - } - return valuesMap.entrySet().stream() .map(entry -> new EfentoTelemetry(entry.getKey(), entry.getValue())) .collect(Collectors.toList()); @@ -446,7 +447,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } } - private EfentoTelemetry getEfentoDeviceInfo(DeviceInfoProtos.ProtoDeviceInfo protoDeviceInfo) { + EfentoTelemetry getEfentoDeviceInfo(DeviceInfoProtos.ProtoDeviceInfo protoDeviceInfo) { JsonObject values = new JsonObject(); values.addProperty("sw_version", protoDeviceInfo.getSwVersion()); @@ -476,41 +477,86 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { //modem info DeviceInfoProtos.ProtoModem modem = protoDeviceInfo.getModem(); - values.addProperty("modem_types", modem.getType().toString()); - values.addProperty("sc_EARNFCN_offset", modem.getParameters(0)); - values.addProperty("sc_EARFCN", modem.getParameters(1)); - values.addProperty("sc_PCI", modem.getParameters(2)); - values.addProperty("sc_Cell_id", modem.getParameters(3)); - values.addProperty("sc_RSRP", modem.getParameters(4)); - values.addProperty("sc_RSRQ", modem.getParameters(5)); - values.addProperty("sc_RSSI", modem.getParameters(6)); - values.addProperty("sc_SINR", modem.getParameters(7)); - values.addProperty("sc_Band", modem.getParameters(8)); - values.addProperty("sc_TAC", modem.getParameters(9)); - values.addProperty("sc_ECL", modem.getParameters(10)); - values.addProperty("sc_TX_PWR", modem.getParameters(11)); - values.addProperty("op_mode", modem.getParameters(12)); - values.addProperty("nc_EARFCN", modem.getParameters(13)); - values.addProperty("nc_EARNFCN_offset", modem.getParameters(14)); - values.addProperty("nc_PCI", modem.getParameters(15)); - values.addProperty("nc_RSRP", modem.getParameters(16)); - values.addProperty("RLC_UL_BLER", modem.getParameters(17)); - values.addProperty("RLC_DL_BLER", modem.getParameters(18)); - values.addProperty("MAC_UL_BLER", modem.getParameters(19)); - values.addProperty("MAC_DL_BLER", modem.getParameters(20)); - values.addProperty("MAC_UL_TOTAL_BYTES", modem.getParameters(21)); - values.addProperty("MAC_DL_TOTAL_BYTES", modem.getParameters(22)); - values.addProperty("MAC_UL_total_HARQ_Tx", modem.getParameters(23)); - values.addProperty("MAC_DL_total_HARQ_Tx", modem.getParameters(24)); - values.addProperty("MAC_UL_HARQ_re_Tx", modem.getParameters(25)); - values.addProperty("MAC_DL_HARQ_re_Tx", modem.getParameters(26)); - values.addProperty("RLC_UL_tput", modem.getParameters(27)); - values.addProperty("RLC_DL_tput", modem.getParameters(28)); - values.addProperty("MAC_UL_tput", modem.getParameters(29)); - values.addProperty("MAC_DL_tput", modem.getParameters(30)); - values.addProperty("sleep_duration", modem.getParameters(31)); - values.addProperty("rx_time", modem.getParameters(32)); - values.addProperty("tx_time", modem.getParameters(33)); + DeviceInfoProtos.ModemType modemType = modem.getType(); + values.addProperty("modem_types", modemType.toString()); + values.addProperty("sim_card_identification", modem.getSimCardIdentification()); + values.addProperty("firmware_version", modem.getFirmwareVersion().toString()); + values.addProperty("modem_identification", modem.getModemIdentification()); + if (modem.getModemStatisticsCount() >= 4) { + values.addProperty("modem_transmissions_count", modem.getModemStatistics(0)); + values.addProperty("modem_time_since_last_devinfo", modem.getModemStatistics(1)); + values.addProperty("modem_total_psm_time", modem.getModemStatistics(2)); + values.addProperty("modem_total_active_time", modem.getModemStatistics(3)); + } + switch (modemType) { + case MODEM_TYPE_BC660: + values.addProperty("sc_EARFCN", modem.getParameters(0)); + values.addProperty("sc_EARNFCN_offset", modem.getParameters(1)); + values.addProperty("sc_PCI", modem.getParameters(2)); + values.addProperty("sc_Cell_id", modem.getParameters(3)); + values.addProperty("sc_RSRP", modem.getParameters(4)); + values.addProperty("sc_RSRQ", modem.getParameters(5)); + values.addProperty("sc_RSSI", modem.getParameters(6)); + values.addProperty("sc_SINR", modem.getParameters(7)); + values.addProperty("sc_Band", modem.getParameters(8)); + values.addProperty("sc_TAC", modem.getParameters(9)); + values.addProperty("sc_ECL", modem.getParameters(10)); + values.addProperty("sc_TX_PWR", modem.getParameters(11)); + values.addProperty("op_mode", modem.getParameters(12)); + values.addProperty("nc_EARFCN", modem.getParameters(13)); + values.addProperty("nc_PCI", modem.getParameters(14)); + values.addProperty("nc_RSRP", modem.getParameters(15)); + values.addProperty("nc_RSRQ", modem.getParameters(16)); + values.addProperty("sleep_duration", modem.getParameters(17)); + values.addProperty("rx_time", modem.getParameters(18)); + values.addProperty("tx_time", modem.getParameters(19)); + values.addProperty("PLMN_state", modem.getParameters(20)); + values.addProperty("select_PLMN", modem.getParameters(21)); + break; + case MODEM_TYPE_SHARED_MODEM: + values.addProperty("RSRP", modem.getParameters(0)); + values.addProperty("RSRQ", modem.getParameters(1)); + values.addProperty("RSSI", modem.getParameters(2)); + values.addProperty("SINR", modem.getParameters(3)); + break; + default: + // MODEM_TYPE_UNSPECIFIED, MODEM_TYPE_BC66, MODEM_TYPE_BC66NA + values.addProperty("sc_EARNFCN_offset", modem.getParameters(0)); + values.addProperty("sc_EARFCN", modem.getParameters(1)); + values.addProperty("sc_PCI", modem.getParameters(2)); + values.addProperty("sc_Cell_id", modem.getParameters(3)); + values.addProperty("sc_RSRP", modem.getParameters(4)); + values.addProperty("sc_RSRQ", modem.getParameters(5)); + values.addProperty("sc_RSSI", modem.getParameters(6)); + values.addProperty("sc_SINR", modem.getParameters(7)); + values.addProperty("sc_Band", modem.getParameters(8)); + values.addProperty("sc_TAC", modem.getParameters(9)); + values.addProperty("sc_ECL", modem.getParameters(10)); + values.addProperty("sc_TX_PWR", modem.getParameters(11)); + values.addProperty("op_mode", modem.getParameters(12)); + values.addProperty("nc_EARFCN", modem.getParameters(13)); + values.addProperty("nc_EARNFCN_offset", modem.getParameters(14)); + values.addProperty("nc_PCI", modem.getParameters(15)); + values.addProperty("nc_RSRP", modem.getParameters(16)); + values.addProperty("RLC_UL_BLER", modem.getParameters(17)); + values.addProperty("RLC_DL_BLER", modem.getParameters(18)); + values.addProperty("MAC_UL_BLER", modem.getParameters(19)); + values.addProperty("MAC_DL_BLER", modem.getParameters(20)); + values.addProperty("MAC_UL_TOTAL_BYTES", modem.getParameters(21)); + values.addProperty("MAC_DL_TOTAL_BYTES", modem.getParameters(22)); + values.addProperty("MAC_UL_total_HARQ_Tx", modem.getParameters(23)); + values.addProperty("MAC_DL_total_HARQ_Tx", modem.getParameters(24)); + values.addProperty("MAC_UL_HARQ_re_Tx", modem.getParameters(25)); + values.addProperty("MAC_DL_HARQ_re_Tx", modem.getParameters(26)); + values.addProperty("RLC_UL_tput", modem.getParameters(27)); + values.addProperty("RLC_DL_tput", modem.getParameters(28)); + values.addProperty("MAC_UL_tput", modem.getParameters(29)); + values.addProperty("MAC_DL_tput", modem.getParameters(30)); + values.addProperty("sleep_duration", modem.getParameters(31)); + values.addProperty("rx_time", modem.getParameters(32)); + values.addProperty("tx_time", modem.getParameters(33)); + break; + } //Runtime info DeviceInfoProtos.ProtoRuntime runtimeInfo = protoDeviceInfo.getRuntimeInfo(); @@ -521,7 +567,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { values.addProperty("counter_of_non_confirmable_messages_attempts", runtimeInfo.getMessageCounters(1)); values.addProperty("counter_of_succeeded_messages", runtimeInfo.getMessageCounters(2)); values.addProperty("min_battery_mcu_temp", runtimeInfo.getMinBatteryMcuTemperature()); - values.addProperty("min_battery_voltage", runtimeInfo.getMinBatteryVoltage()); + values.addProperty("min_battery_voltage", runtimeInfo.getBatteryVoltage()); values.addProperty("min_mcu_temp", runtimeInfo.getMinMcuTemperature()); values.addProperty("runtime_errors", runtimeInfo.getRuntimeErrorsCount()); values.addProperty("up_time", runtimeInfo.getUpTime()); @@ -529,8 +575,8 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { return new EfentoTelemetry(System.currentTimeMillis(), values); } - private JsonElement getEfentoConfiguration(byte[] bytes) throws InvalidProtocolBufferException { - return parseString(ProtoConverter.dynamicMsgToJson(bytes, ConfigProtos.getDescriptor().getMessageTypes().get(2))); + JsonElement getEfentoConfiguration(byte[] bytes) throws InvalidProtocolBufferException { + return parseString(ProtoConverter.dynamicMsgToJson(bytes, ConfigProtos.getDescriptor().getMessageTypes().get(0))); } private static String getDate(long seconds) { diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java index 015bf07292..1acac0ec75 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java @@ -59,14 +59,12 @@ public class CoapEfentoUtils { return String.format("%s UTC", simpleDateFormat.format(new Date(timestampInMillis))); } - public static JsonObject setDefaultMeasurements(String serialNumber, boolean batteryStatus, long measurementPeriod, long nextTransmissionAtMillis, long signal, long startTimestampMillis) { + public static JsonObject setDefaultMeasurements(String serialNumber, boolean batteryStatus, long nextTransmissionAtMillis, long signal) { JsonObject values = new JsonObject(); values.addProperty("serial", serialNumber); values.addProperty("battery", batteryStatus ? "ok" : "low"); - values.addProperty("measured_at", convertTimestampToUtcString(startTimestampMillis)); values.addProperty("next_transmission_at", convertTimestampToUtcString(nextTransmissionAtMillis)); values.addProperty("signal", signal); - values.addProperty("measurement_interval", measurementPeriod); return values; } diff --git a/common/transport/coap/src/main/proto/efento/proto_config.proto b/common/transport/coap/src/main/proto/efento/proto_config.proto index ae6d7902b3..1052d338e7 100644 --- a/common/transport/coap/src/main/proto/efento/proto_config.proto +++ b/common/transport/coap/src/main/proto/efento/proto_config.proto @@ -15,338 +15,471 @@ */ syntax = "proto3"; -import "efento/proto_measurement_types.proto"; import "efento/proto_rule.proto"; +import "efento/proto_config_types.proto"; +import "efento/proto_measurement_types.proto"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "ConfigProtos"; -/* Message containing optional channels control parameters */ -message ProtoOutputControlState { - - /* Channel index */ - uint32 channel_index = 1; - - /* Channel state ON/OFF. Range (1 - OFF; 2 - ON) */ - uint32 channel_state = 2; -} - -/* Message containing request data for accessing calibration parameters */ -message ProtoCalibrationParameters { - - /* Request details. Bitmask: */ - /* - calibration_request[0:2] - requested channel number. */ - uint32 calibration_request = 1; - - /* Assignment of a channel. */ - uint32 channel_assignment = 2; - - /* Table of calibration parameters. Max size = 8. */ - repeated int32 parameters = 3; -} - -enum BleAdvertisingPeriodMode { - - /* Invalid value */ - BLE_ADVERTISING_PERIOD_MODE_UNSPECIFIED = 0; +message ProtoConfig { - /* Default behavior - faster advertising when measurement period is < 15s. */ - BLE_ADVERTISING_PERIOD_MODE_DEFAULT = 1; + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ - /* User-configured normal interval is used. */ - BLE_ADVERTISING_PERIOD_MODE_NORMAL = 2; + reserved 1,48; - /* User-configured fast interval is used. */ - BLE_ADVERTISING_PERIOD_MODE_FAST = 3; -} + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ -/* Message containing BLE advertising period configuration */ -message ProtoBleAdvertisingPeriod { + /* Serial number of the device. * + * Length: 6 bytes. * + * This field is only sent by the device. * + * Status: In use [06.00 - LATEST] */ + bytes serial_number = 25; - /* BLE advertising mode: */ - /* - 1: Default, BLE advertising interval is set to 1022.5ms or some lower value, based on continuous measurement period. */ - /* - 2: Normal, uses user-configured value from 'normal' field. */ - /* - 3: Fast, uses user-configured value from 'fast' field (must be lower than or equal to 'normal' field). */ - BleAdvertisingPeriodMode mode = 1; + /* Configuration payload split information: * + * - Values < 0 - Payload split, expect another part of the payload in the next message. * + * The absolute value indicates an index of the current message * + * - Values = 0 - Payload not split * + * - Values > 0 - Last part of the split payload, the value indicates the total number of the messages sent * + * This field is only sent by the device. * + * Status: In use [06.08.00 - LATEST] */ + sint32 payload_split_info = 44; - /* BLE advertising interval when in normal mode, configured in 0.625ms steps. */ - /* Range: [32:16384] */ - uint32 normal = 2; + /* Identifier of the current configuration. * + * The value of this field changes with every configuration change. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as hash [06.00 - 06.xx.xx] */ + uint32 configuration_hash = 21; + + /* Timestamp when the new configuration was set. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as hash_timestamp [06.02 - 06.xx.xx] */ + uint32 configuration_hash_timestamp = 39; + + /* Configuration errors. * + * Up to 20 error codes supported. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as errors [06.00 - 06.xx.xx] */ + repeated uint32 configuration_errors = 20; + + /* Timestamp when a new configuration error was reported. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as timestamp [06.02 - 06.xx.xx] */ + uint32 configuration_error_timestamp = 38; + + /* Measurement channel types. * + * This field is only sent by the device. * + * Status: In use [06.00 - LATEST] */ + repeated MeasurementType channel_types = 27; - /* BLE advertising interval when in fast mode, configured in 0.625ms steps. */ - /* Range: [32:16384] */ - uint32 fast = 3; -} + /* NvM status: * + * - 1 - Defaults restored on CRC error: default sensor configuration restored due to the non-volatile memory configuration * + * data integrity failure * + * - 2 - NvM initialization error: changes in the sensor configuration won't be stored in the non-volatile memory, a power * + * reset of the sensor is required * + * When empty field is sent by the sensor NvM status is OK. * + * NvM status can be cleared by sending to the sensor value 0x7F. * + * Status: In use [07.00.00 - LATEST] */ + uint32 nvm_status = 62; + + /* SERVER STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ + + /* Current time in seconds since 1st of January 1970 (epoch time). * + * This field is only sent by the server. * + * Status: In use [06.00 - LATEST] */ + uint32 current_time = 8; -/* Main message sent in the payload. Each field in this message is independent of the others - only parameters that should be */ -/* changed need to be sent in the payload. */ -/* If the value of a selected parameter shall not be changed, do not include it in the payload */ -message ProtoConfig { + /* REQUEST FIELDS ----------------------------------------------------------------------------------------------------------- */ + + /* Specifies whether the device should accept the configuration without functional testing (e.g., network connection). * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as accept_without_testing [06.00 - 06.xx.xx] */ + bool accept_without_testing_request = 22; + + /* Specifies whether to send the configuration from the sensor to the configuration endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_configuration [06.00 - 06.xx.xx] */ + bool configuration_request = 19; + + /* Specifies whether to send the device information from the sensor to the device information endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_device_info [06.00 - 06.xx.xx] */ + bool device_info_request = 6; + + /* Specifies whether to send the extended configuration from the sensor to the extended configuration endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + bool extended_configuration_request = 63; + + /* Specifies, if software update is available. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_fw_update [06.00 - 06.xx.xx] */ + bool update_software_request = 7; + + /* Device will clear all runtime errors. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_runtime_errors_clear [06.02 - 06.xx.xx] */ + bool clear_runtime_errors_request = 37; + + /* Device will power off its cellular modem for requested number of seconds. * + * Range: [60:604800] (1 minute : 7 days) * + * This field is only sent by the user/server. * + * Status: In use [06.00 - LATEST] */ + uint32 disable_modem_request = 18; - /* DEPRECATED - Used for backward compatibility with fw versions 5.x */ - /* repeated Threshold thresholds = 1; */ - - /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ - /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ - /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ - /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ - /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ - /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ - /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ - /* measurement period base in seconds */ - /* Range [1:65535] - minimum value can vary depends on installed sensors */ + /* Specifies, if the modem firmware update is available. * + * String up to 48 characters: * + * - DFOTA URL - For use by BC66/BC660 modem. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as modem_update_request [06.08.00 - 06.xx.xx] */ + string update_modem_request = 45; + + /* Device will erase all measurements from memory. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as memory_reset_request [06.00 - 06.xx.xx] */ + bool reset_memory_request = 30; + + /* Device will restart the collection of memory statistics. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + bool restart_memory_stats_request = 64; + + /* Specifies whether to send measurements from the sensor starting at the specified timestamp. * + * Tiemstamp in seconds since 1st of January 1970 (epoch time). * + * All previous measurements will be marked as sent. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + uint32 data_transfer_start_timestamp_request = 65; + + /* Device will set new calibration parameters or will send the current set of parameters to the configuration endpoint. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + ProtoCalibrationParametersRequest calibration_parameters_request = 49; + + /* Device will set the new output state of the output control channel pins. * + * Up to 3 channels supported in the one request. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as output_control_state_request [06.13.00/06.21.00 - 06.xx.xx] */ + repeated ProtoOutputControlState set_output_control_state_request = 58; + + /* MEASUREMENTS CONFIGURATION ----------------------------------------------------------------------------------------------- */ + + /* Measurement period defines how often the measurements are to be taken. * + * Sensors of 'Continuous' type take measurement each 'measurement_period_base' * 'measurement_period_factor'. * + * Sensors of 'Binary' type take measurement each 'measurement_period_base'. * + * For backward compatibility with versions 5.xx in case of 'Binary/Mixed' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '14' shall be used for the period calculation. * + * For backward compatibility with versions 5.xx in case of 'Continuous' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '1' shall be used for the period calculation. */ + + /* Measurement period base in seconds. * + * Range: [1:65535] (minimum value may vary depending on sensors installed) * + * Group: Measurement Period * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_base = 2; - /* Measurement period factor */ - /* Range [1:65535] - minimum value can vary depends on installed sensors */ + /* Measurement period factor. * + * Range: [1:65535] (minimum value may vary depending on sensors installed) * + * Group: Measurement Period * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_factor = 26; - /* Transmission interval in seconds. Range: [60:604800] */ - uint32 transmission_interval = 3; + /* BLUETOOTH CONFIGURATION -------------------------------------------------------------------------------------------------- */ - /* BLE turnoff time in seconds. Once receiving this setting, BLE will be switched off after the set number of seconds. */ - /* If BLE is already switched off, it will switch on for the set number of seconds and switch off afterwards. */ - /* Range [60:604800] and 0xFFFFFFFF */ - /* 0xFFFFFFFF - always on */ + /* Bluetooth turn-off time: * + * - [60:604800] - Time in seconds after which Bluetooth is turned off * + * - 0xFFFFFFFF - Bluetooth is always on * + * When this setting is received, Bluetooth is turned off after the set number of seconds. * + * If Bluetooth is already off, it will turn on for the set number of seconds and then turn off. * + * Group: Bluetooth Turn-Off * + * Status: In use [06.00 - LATEST] */ uint32 ble_turnoff_time = 4; - /* ACK interval in seconds */ - /* Range [180:2592000] and 0xFFFFFFFF */ - /* 0xFFFFFFFF - always request ACK */ - uint32 ack_interval = 5; - - /* Specifies, if the additional device info is requested. If true, sensor will send a message to endpoint '/i' with the */ - /* device info. This field is only sent by server */ - bool request_device_info = 6; - - /* Specifies, if software update is available. This field is only sent by server */ - bool request_fw_update = 7; - - /* Current time in seconds sine 1st of January 1970 (epoch time). */ - uint32 current_time = 8; - - /* NB-IoT transfer limit */ - /* Range: [1:65535] */ - /* 65535 - disable transfer limit function */ - uint32 transfer_limit = 9; - - /* NB-IoT transfer limit timer in seconds */ - /* Range: [1:65535] */ - /* 65535 - disable transfer limit function */ - uint32 transfer_limit_timer = 10; - - /* For firmware >= 6.07.00: */ - /* IP or URL address of the data (measurements) server */ - /* The IP or URL of the data server, provided as string with a maximum length of 31 characters */ - /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ - /* For firmware < 6.07.00: */ - /* IP address of the data (measurements) server */ - /* For example, use "18.184.24.239" */ - string data_server_ip = 11; - - /* Data (measurements) server port */ - /* Range: [1:65535] */ - uint32 data_server_port = 12; - - /* For firmware >= 6.07.00: */ - /* IP or URL address of the update server */ - /* The IP or URL of the update server, provided as string with a maximum length of 31 characters */ - /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ - /* For firmware < 6.07.00: */ - /* IP address of the update server */ - /* For example, use "18.184.24.239" */ - string update_server_ip = 13; - - /* Update server port for UDP transmission */ - /* Range: [1:65535] */ - uint32 update_server_port_udp = 14; - - /* Update server port for CoAP transmission */ - /* Range: [1:65535] */ - uint32 update_server_port_coap = 15; - - /* APN as string. Max length 49 */ - /* String with special character 0x7F (DEL) only indicates that automatic apn is turn on */ - string apn = 16; + /* Bluetooth Tx power level. * + * Value is the index of the absolute value of the Tx power, which depends on the BLE module. * + * Range: [1:4] * + * Group: Bluetooth Tx Power * + * Status: In use [06.02 - LATEST] */ + uint32 ble_tx_power_level = 36; - /* PLMN selection */ - /* Range: [100:999999] */ - /* 0xFFFFFFFF or 1000000 - automatic selection */ - uint32 plmn_selection = 17; + /* Encryption key. * + * Max length: 16 bytes. * + * A one-element array containing only one 0x7F (DEL) byte disables encryption. * + * Device sends two last bytes of SHA256 hash of current key in this field. * + * When the encryption key is disabled, the device sends a 0x7F (DEL) byte. * + * Group: Encryption * + * Status: In use [06.11.00 - LATEST] */ + bytes encryption_key = 54; - /* Device will power off its cellular modem for requested number of seconds. */ - /* Range: [60:604800] (1 minute : 7 days) */ - /* This field is only sent by server */ - uint32 disable_modem_request = 18; + /* Bluetooth advertising period. * + * Group: Bluetooth Advertising Period * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + ProtoBleAdvertisingPeriod ble_advertising_period = 59; - /* If set, the device will send its configuration to the endpoint '/c' as a confirmable message */ - /* This field is only sent by server */ - bool request_configuration = 19; + /* Advertisement manufacturer specific data format. * + * Group: Advertisement Manufacturer Data Format * + * Status: In use [07.00.00 - LATEST] */ + AdvertisementManufacturerDataFormat advertisement_manufacturer_data_format = 60; - /* Device's error codes. */ - /* This field is only sent by device */ - repeated uint32 errors = 20; + /* EDGE LOGIC CONFIGURATION ------------------------------------------------------------------------------------------------- */ - /* Identifier of current configuration - Every change of the configuration results in change of the value of this field */ - /* This field is only sent by device */ - uint32 hash = 21; + /* Edge logic rules set on the device. * + * Up to 16 rules supported (previously 12 rules [06.00 - 07.01.xx]). * + * Group: Edge Logic Rule No. # * + * Status: In use [06.00 - LATEST] */ + repeated ProtoRule rules = 28; - /* If true, the device will accept the configuration without functional testing (eg. network connection) */ - bool accept_without_testing = 22; + /* Calendars set on the device. * + * Up to 6 calendars supported. * + * Group: Calendar No. # * + * Status: In use [06.08.00 - LATEST] */ + repeated ProtoCalendar calendars = 47; - /* Cloud token configuration: */ - /* - 1: cloud token set to the value of cloud_token field */ - /* - 2: cloud token set to IMEI of the cellular module */ - /* - 255: do not send cloud_token field */ - uint32 cloud_token_config = 23; + /* SERVER COMMUNICATION CONFIGURATION --------------------------------------------------------------------------------------- */ - /* Cloud token that should be sent with each measurement frame */ - string cloud_token = 24; + /* Transmission interval in seconds. * + * Range: [60:604800] * + * Group: Server Intervals * + * Status: In use [06.00 - LATEST] */ + uint32 transmission_interval = 3; - /* Serial number of the device */ - /* This field is only sent by device */ - bytes serial_number = 25; + /* ACK interval: * + * - [180:2592000] - Time in seconds after which the device will request an ACK * + * - 0xFFFFFFFF - Always request ACK * + * Group: Server Intervals * + * Status: In use [06.00 - LATEST] */ + uint32 ack_interval = 5; - /* Type of channel */ - /* This field is only sent by device */ - repeated MeasurementType channel_types = 27; + /* Server transfer limit: * + * - [1:65534] - Number of transfers * + * - 65535 - Transfer limit disabled * + * Group: Server Transfer Limit * + * Status: In use [06.00 - LATEST] */ + uint32 transfer_limit = 9; - /* Edge logic rules set on the device. Up to 12 rules are supported */ - repeated ProtoRule rules = 28; + /* Server transfer limit timer: * + * - [1:65534] - Time in seconds after which the transfer is renewed * + * - 65535 - Transfer limit disabled * + * Group: Server Transfer Limit * + * Status: In use [06.00 - LATEST] */ + uint32 transfer_limit_timer = 10; - /* Supervision period */ - /* Range: [180:604800] */ - /* 0xFFFFFFFF - Functionality disabled */ + /* Server supervision period: * + * - [180:604800] - Time in seconds after which the device resets itself if there is no communication with the server * + * - 0xFFFFFFFF - Server supervision disabled * + * Group: Server Supervision * + * Status: In use [06.00 - LATEST] */ uint32 supervision_period = 29; - /* If true, sensor's measurement memory will be erased */ - bool memory_reset_request = 30; + /* DATA SERVER CONFIGURATION ------------------------------------------------------------------------------------------------ */ - /* Bytes 0-4 - Band selection mask. Mask = 1 << position */ - /* Band | 1 | 2 | 3 | 4 | 5 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 25 | 26 | 28 | 66 | 71 | 85 | */ - /* Position: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | */ - /* example: To enable band 3, 8 and 20 set to (1 << 2) + (1 << 5) + (1 << 11) = 2084 */ - uint32 modem_bands_mask = 31; + /* Data server address. * + * String up to 31 characters: * + * - IPv4 address (examples: "18.184.24.239") [06.00 - 06.06.xx] * + * - IPv4/URL address (examples: "18.184.24.239", "efento.test.io") [06.07.00 - LATEST] * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + string data_server_ip = 11; + + /* Data server port. * + * Range: [1:65535] * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 data_server_port = 12; - /* Data endpoint (string - max length 16) */ + /* Data endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [06.02 - LATEST] */ string data_endpoint = 32; - /* Configuration endpoint (string - max length 16) */ + /* Configuration endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [06.02 - LATEST] */ string configuration_endpoint = 33; - /* Device info endpoint (string - max length 16) */ + /* Extended configuration endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [07.00.00 - LATEST] */ + string extended_configuration_endpoint = 61; + + /* Device information endpoint. * + * String up to 11 characters. * + * Group: Device Information Endpoint * + * Status: In use [06.02 - LATEST] */ string device_info_endpoint = 34; - /* Time endpoint (string - max length 16) */ + /* Time endpoint. * + * String up to 11 characters. * + * Group: Time Endpoint * + * Status: In use [06.02 - LATEST] */ string time_endpoint = 35; - /* Bluetooth TX power level. Value is the index of the absolute value of TX power, that depends on the BLE module */ - /* Range: [1:4] */ - uint32 ble_tx_power_level = 36; + /* UPDATE SERVER CONFIGURATION ---------------------------------------------------------------------------------------------- */ - /* Deprecated field */ - /* If true, the sensor's runtime errors will be cleared */ - bool request_runtime_errors_clear = 37; + /* Update server address. * + * String up to 31 characters: * + * - IPv4 address (examples: "18.184.24.239") [06.00 - 06.06.xx] * + * - IPv4/URL address (examples: "18.184.24.239", "efento.test.io") [06.07.00 - LATEST] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + string update_server_ip = 13; - /* Timestamp when a new error code was reported */ - uint32 error_timestamp = 38; + /* Update server port for UDP transfer. * + * Range: [1:65535] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + uint32 update_server_port_udp = 14; - /* Timestamp when the new configuration was set */ - uint32 hash_timestamp = 39; + /* Update server port for CoAP transfer. * + * Range: [1:65535] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + uint32 update_server_port_coap = 15; - /* Cloud token CoAP option ID: */ - /* - [1:64999] - CoAP option ID containing cloud token */ - /* - 65000 - cloud token sent in the payload */ - uint32 cloud_token_coap_option = 40; + /* CLOUD CONFIGURATION ------------------------------------------------------------------------------------------------------ */ - /* ECDSA payload signature CoAP option ID: */ - /* - [1:64999] - CoAP option ID containing ECDSA payload signature */ - /* - 65000 - no payload signature in CoAP option */ - uint32 payload_signature_coap_option = 41; + /* Cloud token configuration: * + * - 1 - Cloud token set to the value of the 'cloud_token' field * + * - 2 - Cloud token set to the modem identification (IMEI for cellular modems) * + * - 255 - Do not send 'cloud_token' field * + * Group: Cloud Token * + * Status: In use [06.00 - LATEST] */ + uint32 cloud_token_config = 23; - /* DNS server IP address grouped in the array as four octets. Set 255.255.255.255 to use a network DNS server */ - /* Note: when setting less than four octets the remaining will be filled with zeros. */ - repeated uint32 dns_server_ip = 42; + /* Cloud token that should be sent with each measurement frame. * + * String up to 36 characters. * + * Group: Cloud Token * + * Status: In use [06.00 - LATEST] */ + string cloud_token = 24; - /* DNS TTL configuration: */ - /* - [1:864000] - custom TTL in seconds (additionally, the DNS request when communication has failed) */ - /* - 864001 - accept TTL from the DNS server (additionally, the DNS request when communication has failed) */ - /* - 864002 - DNS request is only after communication failed */ - uint32 dns_ttl_config = 43; + /* Cloud token CoAP option ID: * + * - [1:64999] - CoAP option ID with cloud token * + * - 65000 - Cloud token sent in the payload * + * Group: Cloud Token * + * Status: In use [06.07.00 - LATEST] */ + uint32 cloud_token_coap_option = 40; - /* Configuration payload split information. Information about dividing the payload into parts */ - /* values < 0 - payload has been split, expect another part of the payload in the next message. */ - /* The absolute value indicates an index of the current message. */ - /* value = 0 - payload has not been splitted */ - /* values > 0 - last part of the split payload, the value indicates the total number of the messages sent */ - sint32 payload_split_info = 44; + /* ECDSA payload signature CoAP option ID: * + * - [1:64999] - CoAP option ID with payload signature * + * - 65000 - Payload signature is not sent * + * Group: Cloud Token * + * Status: In use [06.07.00 - LATEST] */ + uint32 payload_signature_coap_option = 41; - /* Modem update request (string - max length 48) */ - /* This field is only sent by server */ - /* For BC66 module, this field is a DFOTA URL */ - string modem_update_request = 45; + /* Modem identification CoAP option ID: * + * - [1:64999] - CoAP option ID with modem identification * + * - 65000 - Modem identification is not sent * + * Group: Cloud Token * + * Status: In use [07.00.00 - LATEST] */ + uint32 modem_identification_coap_option = 52; - /* Cellular configuration parameters. */ - /* 1st item - Number of used cellular parameters */ - /* 2nd - 12th items - Cellular parameters */ - repeated uint32 cellular_config_params = 46; + /* NETWORK CONFIGURATION ---------------------------------------------------------------------------------------------------- */ - /* Calendar configuration. Up to 6 calendars are supported */ - repeated ProtoCalendar calendars = 47; + /* DNS server IP address. * + * Grouped in the array as four octets. Set 255.255.255.255 to use a cellular network DNS server. * + * Note: when setting less than four octets the remaining will be filled with zeros. * + * Group: Data Server * + * Status: In use [06.07.00 - LATEST] */ + repeated uint32 dns_server_ip = 42; - /* DEPRECATED - Used for backward compatibility */ - reserved 48; - - /* Set/get calibration parameters for single channel. */ - ProtoCalibrationParameters calibration_parameters_request = 49; - - /* LED behaviour configuration: */ - /* Period of LEDs flashing (5-600 seconds in 5 seconds resolution): */ - /* - led_config[0] - green LED */ - /* - led_config[1] - red LED */ - /* Time from entering the normal state, after which the LED indication is turned off */ - /* (0-240 minutes in 1 minute resolution, or 255 for always turned on): */ - /* - led_config[2] - flashing red led on communication problem */ - /* - led_config[3] - flashing red led on a sensor problem */ - /* - led_config[4] - flashing red led on a low power */ - /* - led_config[5] - flashing green led on measurement */ - /* - led_config[6] - flashing green led on transmission */ - /* - led_config[7] - flashing green led to indicate sensor's proper operation */ - /* - led_config[8] - Blink duration (20-1000ms in 5 ms resolution) */ - repeated uint32 led_config = 50; + /* DNS TTL configuration: * + * - [1:864000] - Custom TTL in seconds (if communication fails, DNS is also queried) * + * - 864001 - Accept TTL from the DNS server (if communication fails, DNS is also queried) * + * - 864002 - DNS query only after communication failure * + * Group: DNS * + * Status: In use [06.07.00 - LATEST] */ + uint32 dns_ttl_config = 43; - /* Network troubleshooting configuration, if bluetooth is turned off and communication with the server is faulty, */ - /* bluetooth will be automatically turned on until the connection is stabilized */ - /* - 1: network troubleshooting disabled */ - /* - 2: network troubleshooting enabled */ + /* Network troubleshooting: + * - 1 - Network troubleshooting disabled * + * - 2 - Network troubleshooting enabled * + * If Bluetooth is turned off and communication with the server is faulty, Bluetooth will be automatically turned on until * + * the connection is stabilized. * + * Group: Network * + * Status: In use [06.10.00 - LATEST] */ uint32 network_troubleshooting = 51; - /* Reserved by gateway client */ - reserved 52, 53; - - /* Encryption key configuration. Sensor sends in this field two last bytes of SHA256 hash calculated from its current */ - /* encryption_key configuration. When encryption key is disabled one byte 0x7F (DEL) is sent. */ - /* Max length: 16 bytes. */ - /* 0x7F - encryption key disabled. */ - bytes encryption_key = 54; - - /* User name as string. Max length 31 */ - /* String with special character 0x7F (DEL) only indicates that automatic user name is turn on */ - /* User name can only be set to custom value if apn has been configured (is not automatic) */ - string apn_user_name = 55; + /* Network key. * + * Max length: 16 bytes. * + * A one-element array containing only one 0x7F (DEL) byte disables network key. * + * Device sends two last bytes of SHA256 hash of current key in this field. * + * When the network key is disabled, the device sends a 0x7F (DEL) byte. * + * Group: Local Network * + * Status: In use [07.00.00 - LATEST] */ + bytes network_key = 53; + + /* MODEM CONFIGURATION ------------------------------------------------------------------------------------------------------ */ + + /* APN (Access Point Name). * + * String up to 49 characters. * + * A string containing only the special character 0x7F (DEL) indicates that automatic APN is enabled. * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + string apn = 16; - /* Password as string. Max length 31 */ - /* String with special character 0x7F (DEL) only indicates that automatic password is turn on */ - /* Password can only be set to custom value if apn_user_name has been configured (is not automatic) */ + /* APN username. * + * String up to 31 characters. * + * A string containing only the special character 0x7F (DEL) indicates that the username is not being used. * + * The username can only be set to a custom value if APN has been configured. * + * Group: Data Server * + * Status: In use [07.00.00 - LATEST] / Previously as apn_user_name [06.11.00 - 06.xx.xx] */ + string apn_username = 55; + + /* APN password. * + * String up to 31 characters. * + * A string containing only the special character 0x7F (DEL) indicates that the password is not being used. * + * The password can only be set to a custom value if APN username has been configured. * + * Group: Data Server * + * Status: In use [06.11.00 - LATEST] */ string apn_password = 56; - /* Reserved by versions above 06.20.00 */ - reserved 57; + /* PLMN selection: * + * - [100:999999] - Selected operator code * + * - 1000000 - Automatic selection * + * - 0xFFFFFFFF - Automatic selection (legacy) * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 plmn_selection = 17; - /* Control output state on channel pin. Maximal number of requests equals 3 */ - /* This field is only sent by server */ - repeated ProtoOutputControlState output_control_state_request = 58; + /* Modem bands mask. * + * 4-byte bit mask, where each bit represents a specific band: * + * Band: | 1 | 2 | 3 | 4 | 5 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 25 | 26 | 28 | 66 | 71 | 85 | 70 | * + * Bit: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | * + * Example: To enable band 3, 8 and 20 set to 2084 = (1 << 2) + (1 << 5) + (1 << 11) * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 modem_bands_mask = 31; - /* BLE advertising period configuration. */ - ProtoBleAdvertisingPeriod ble_advertising_period = 59; -} + /* Modem configuration parameters. * + * 1st item - The number of cellular parameters that are used (depending on the modem in use). * + * 2nd - 16th item - Cellular parameters. * + * Group: Cellular Configuration * + * Status: In use [06.08.00 - LATEST] */ + repeated uint32 cellular_config_params = 46; + + /* Network search schema. * + * Group: Network Search * + * Status: In use [06.20.00 - LATEST] */ + ProtoNetworkSearch network_search = 57; + + /* USER INTERFACE CONFIGURATION --------------------------------------------------------------------------------------------- */ + + /* LEDs behavior. * + * Period of LED blinking in 5-second unit (value is multiplied by 5). Range: [1:120]: * + * - 1st item - Period of GREEN LED blinking * + * - 2nd item - Period of RED LED blinking * + * Time elapsing from the entry into the normal state, after which the LED indicator is switched off, in minutes. * + * Range: [0:240; 255]. Value of 255 means always on: * + * - 3rd item - Blinking RED LED on communication problem * + * - 4th item - Blinking RED LED on a sensor problem * + * - 5th item - Blinking RED LED on a low power * + * - 6th item - Blinking GREEN LED on measurement * + * - 7th item - Blinking GREEN LED on transmission * + * - 8th item - Blinking GREEN LED to indicate sensor's proper operation * + * 9th item - Duration of LED blinking in 5-millisecond unit. Range: [4:200] * + * Group: LED * + * Status: In use [06.10.00 - LATEST] */ + repeated uint32 led_config = 50; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_config_types.proto b/common/transport/coap/src/main/proto/efento/proto_config_types.proto new file mode 100644 index 0000000000..44fb9197a4 --- /dev/null +++ b/common/transport/coap/src/main/proto/efento/proto_config_types.proto @@ -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; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_device_info.proto b/common/transport/coap/src/main/proto/efento/proto_device_info.proto index 3e1f066c59..f3908826ad 100644 --- a/common/transport/coap/src/main/proto/efento/proto_device_info.proto +++ b/common/transport/coap/src/main/proto/efento/proto_device_info.proto @@ -18,185 +18,330 @@ syntax = "proto3"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "DeviceInfoProtos"; +message ProtoRuntime +{ + + /* Up-time in seconds (since reset). * + * Status: In use [06.00 - LATEST] */ + uint32 up_time = 1; + + /* Message counters (since reset). * + * 1st item - Confirmable message attempts counter. * + * 2nd item - Non-confirmable message attempts counter. * + * 3rd item - Successful message counter. * + * Status: In use [06.00 - LATEST] */ + repeated uint32 message_counters = 2; + + /* MCU temperature in Celsius. * + * Status: In use [06.00 - LATEST] */ + sint32 mcu_temperature = 3; + + /* Battery voltage in mV. * + * - [0:65534] - Battery voltage in millivolts * + * - 65535 - No measurement * + * Status: In use [07.00.00 - LATEST] / Previously as min_battery_voltage [06.00 - 06.xx.xx] */ + uint32 battery_voltage = 4; + + /* MCU temperature in Celsius when minimum battery voltage was reached. * + * Status: Deprecated [06.00 - 06.xx.xx] */ + sint32 min_battery_mcu_temperature = 5; + + /* Battery reset timestamp in seconds since 1st of January 1970 (epoch time). * + * Status: Deprecated [06.00 - 06.xx.xx] */ + uint32 battery_reset_timestamp = 6; + + /* Maximum MCU temperature in Celsius. * + * Status: In use [06.00 - LATEST] */ + sint32 max_mcu_temperature = 7; + + /* Minimum MCU temperature in Celsius * + * Status: In use [06.00 - LATEST] */ + sint32 min_mcu_temperature = 8; + + /* Device's runtime errors. * + * Up to 20 error items supported. * + * Status: In use [06.02 - LATEST] */ + repeated uint32 runtime_errors = 9; + + /* Number of sensor resets (since power-up). * + * Status: In use [06.21.00 - LATEST] */ + uint32 reset_counter = 10; +} + +message ProtoUpdateInfo +{ + + /* Timestamp of the last update check in seconds since 1st of January 1970 (epoch time). * + * Status: In use [06.00 - LATEST] */ + uint32 timestamp = 1; + + /* Status of the last update check: * + * - 1 - No update check * + * - 2 - No error * + * - 3 - UDP socekt error * + * - 4 - Invalid hash * + * - 5 - Missing packet * + * - 6 - Invalid data * + * - 7 - Sending timeout * + * - 8 - No software to update * + * - 9 - Sending unexpected error * + * - 10 - Unexpected error * + * Status: In use [06.00 - LATEST] */ + uint32 status = 2; +} + enum ModemType { - /* Invalid value */ + /* Invalid value. */ MODEM_TYPE_UNSPECIFIED = 0; - /* Quectel BC66 modem */ + /* Quectel BC66 modem. */ MODEM_TYPE_BC66 = 1; - /* Quectel BC66-NA modem */ + /* Quectel BC66-NA modem. */ MODEM_TYPE_BC66NA = 2; + + /* Uses a shared modem from another sensor. */ + MODEM_TYPE_SHARED_MODEM = 3; + + /* Quectel BC660 modem. */ + MODEM_TYPE_BC660 = 4; } -message ProtoRuntime +enum ModemFirmwareVersion { + /* Invalid value. */ + MODEM_FIRMWARE_VERSION_UNSPECIFIED = 0; - /* Up-time in seconds (since reset) */ - uint32 up_time = 1; + /* Unable to read firmware version from device. */ + MODEM_FIRMWARE_VERSION_READING_ERROR = 1; - /* Message counters (since reset). There are 3 counters: */ - /* message_counters[0] - Counter of confirmable messages attempts */ - /* message_counters[1] - Counter of non-confirmable messages attempts */ - /* message_counters[2] - Counter of succeeded messages */ - repeated uint32 message_counters = 2; + /* Unknown firmware version. */ + MODEM_FIRMWARE_VERSION_UNKNOWN = 2; - /* MCU temperature in Celsius */ - sint32 mcu_temperature = 3; + /* BC660KGLAAR01A05_01.002.01.002. */ + MODEM_FIRMWARE_VERSION_BC660_V1 = 3; - /* Minimum battery voltage in mV */ - uint32 min_battery_voltage = 4; + /* BC660KGLAAR01A05_01.200.01.200. */ + MODEM_FIRMWARE_VERSION_BC660_V2 = 4; - /* MCU temperature in Celsius, while the minimum battery voltage was reached */ - sint32 min_battery_mcu_temperature = 5; + /* BC660KGLAAR01A05_01.202.01.202. */ + MODEM_FIRMWARE_VERSION_BC660_V3 = 5; - /* Battery reset timestamp (Unix timestamp) */ - uint32 battery_reset_timestamp = 6; + /* BC660KGLAAR01A05_01.203.01.203. */ + MODEM_FIRMWARE_VERSION_BC660_V4 = 6; - /* Max MCU temperature in Celsius */ - sint32 max_mcu_temperature = 7; + /* BC660KGLAAR01A05_01.204.01.204. */ + MODEM_FIRMWARE_VERSION_BC660_V5 = 7; - /* Min MCU temperature in Celsius */ - sint32 min_mcu_temperature = 8; + /* BC660KGLAAR01A05_01.205.01.205. */ + MODEM_FIRMWARE_VERSION_BC660_V6 = 8; - /* Table of runtime errors. Max length: 20 */ - repeated uint32 runtime_errors = 9; + /* BC660KGLAAR01A05_01.301.01.301. */ + MODEM_FIRMWARE_VERSION_BC660_V7 = 9; + + /* BC660KGLAAR01A05_01.303.01.303. */ + MODEM_FIRMWARE_VERSION_BC660_V8 = 10; } message ProtoModem { + /* Modem type. * + * Status: In use [06.00 - LATEST] */ ModemType type = 1; - /* Parameters for BC66 modem: */ - /* parameters[0] - sc_EARFCN - Range: [0:262143]. Unknown value: -1 */ - /* parameters[1] - sc_EARNFCN_offset - Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 */ - /* parameters[2] - sc_PCI - Range: [0:502]. Unknown value: -1 */ - /* parameters[3] - sc_Cell id - Range: [1:268435456]. Unknown value: 0 */ - /* parameters[4] - sc_RSRP - [dBm] - Range: [-140:-44]. Unknown value: 0 */ - /* parameters[5] - sc_RSRQ - [dB] - Range: [-20:-3]. Unknown value: 0 */ - /* parameters[6] - sc_RSSI - [dBm] - Range: [-110:-3] Unknown value: 0 */ - /* parameters[7] - sc_SINR - [dB] - Range: [-10:30]. Unknown value: 31 */ - /* parameters[8] - sc_Band - Range: [see module supported bands]. The current serving cell band. Unknown value: -1 */ - /* parameters[9] - sc_TAC - Range: [0:65536]. Unknown value: -1 */ - /* parameters[10] - sc_ECL - Range: [0:2]. Unknown value: -1 */ - /* parameters[11] - sc_TX_PWR - [0.1cBm] - Range [-440:230]. Unknown value: -1000 */ - /* parameters[12] - OP_MODE - Range: [0:3]. Unknown value: -1 */ - /* parameters[13] - nc_EARFCN - Range: [0:262143]. Unknown value: -1 */ - /* parameters[14] - nc_EARNFCN_offset - Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 */ - /* parameters[15] - nc_PCI - Range: [0:502]. Unknown value: -1 */ - /* parameters[16] - nc_RSRP - [dBm] - Range: [-140:-44]. Unknown value: 0 */ - /* parameters[17] - RLC_UL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[18] - RLC_DL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[19] - MAC_UL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[20] - MAC_DL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[21] - MAC_UL_TOTAL_BYTES - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[22] - MAC_DL_TOTAL_BYTES - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[23] - MAC_UL_total_HARQ_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[24] - MAC_DL_total_HARQ_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[25] - MAC_UL_HARQ_re_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[26] - MAC_DL_HARQ_re_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[27] - RLC_UL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[28] - RLC_DL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[29] - MAC_UL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[30] - MAC_DL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[31] - sleep_duration - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[32] - Rx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[33] - Tx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ + /* Modem runtime parameters. * + * For BC66 modem (34 parameters): * + * - 1st item - sc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 2nd item - sc_EARNFCN_offset. Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 * + * - 3rd item - sc_PCI. Range: [0:502]. Unknown value: -1 * + * - 4th item - sc_Cell_Id. Range: [1:268435456]. Unknown value: 0 * + * - 5th item - sc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 6th item - sc_RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 7th item - sc_RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 8th item - sc_SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * - 9th item - sc_Band. Range: [see module supported bands]. The current serving cell band. Unknown value: -1 * + * - 10th item - sc_TAC. Range: [0:65536]. Unknown value: -1 * + * - 11th item - sc_ECL. Range: [0:2]. Unknown value: -1 * + * - 12th item - sc_TX_PWR [0.1dBm]. Range [-440:230]. Unknown value: -1000 * + * - 13th item - OP_MODE. Range: [0:3]. Unknown value: -1 * + * - 14th item - nc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 15th item - nc_EARNFCN_offset. Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 * + * - 16th item - nc_PCI. Range: [0:502]. Unknown value: -1 * + * - 17th item - nc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 18th item - RLC_UL_BLER. Range: [0:100]. Unknown value: -1 * + * - 19th item - RLC_DL_BLER. Range: [0:100]. Unknown value: -1 * + * - 20th item - MAC_UL_BLER. Range: [0:100]. Unknown value: -1 * + * - 21th item - MAC_DL_BLER. Range: [0:100]. Unknown value: -1 * + * - 22th item - MAC_UL_TOTAL_BYTES. Range: [0:2147483647]. Unknown value: -1 * + * - 23th item - MAC_DL_TOTAL_BYTES. Range: [0:2147483647]. Unknown value: -1 * + * - 24th item - MAC_UL_total_HARQ_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 25th item - MAC_DL_total_HARQ_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 26th item - MAC_UL_HARQ_re_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 27th item - MAC_DL_HARQ_re_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 28th item - RLC_UL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 29th item - RLC_DL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 30th item - MAC_UL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 31th item - MAC_DL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 32th item - sleep_duration [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 33th item - Rx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 34th item - Tx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * For BC660 modem (22 parameters): * + * - 1st item - sc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 2nd item - sc_EARNFCN_offset. Range: [0:21] mapped to * + * [Invalid, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, -0.5, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]. * + * Unknown value: -1 * + * - 3rd item - sc_PCI. Range: [0:503]. Unknown value: -1 * + * - 4th item - sc_Cell_Id. Range: [1:268435456]. Unknown value: 0 * + * - 5th item - sc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 6th item - sc_RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 7th item - sc_RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 8th item - sc_SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * - 9th item - sc_Band. Range: [see module supported bands]. The current serving cell band. Unknown value: -1 * + * - 10th item - sc_TAC. Range: [0:65536]. Unknown value: -1 * + * - 11th item - sc_ECL. Range: [0:2]. Unknown value: -1 * + * - 12th item - sc_TX_PWR [dBm]. Range [-45:23]. Unknown value: 128 * + * - 13th item - OP_MODE. Range: [0:3] mapped to [In-band same PCI, In-band different PCI, Guard band, Stand alone]. * + * Unknown value: -1 * + * - 14th item - nc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 15th item - nc_PCI. Range: [0:503]. Unknown value: -1 * + * - 16th item - nc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 17th item - nc_RSRQ [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 18th item - sleep_duration [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 19th item - Rx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 20th item - Tx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 21st item - PLMN_state. Range: [0:3] mapped to [No PLMN, Searching, Selected, Unknown]. Unknown value: 3 * + * - 22nd item - select_PLMN. Range: [100:999999]. Unknown value: -1 * + * For shared modem client (4 parameters): * + * - 1st item - RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 2nd item - RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 3rd item - RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 4th item - SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * Status: In use [06.00 - LATEST] */ repeated sint32 parameters = 2; - /* ICCID of inserted/soldered sim card. String up to 22 characters long. */ - /* 0x7F if sim card is not detected, empty (not sent) if device does not have modem. */ - /* This field is only sent by device */ + /* Integrated Circuit Card Identifier (ICCID) of the inserted/soldered SIM card. * + * String up to 22 characters: * + * A string containing only the special character 0x7F (DEL) indicates that the SIM card is not detected. * + * Status: In use [06.07.04/06.09.09/06.10.10/06.11.09/06.12.06/06.13.00/06.21.00 - LATEST] */ string sim_card_identification = 3; + + /* Modem firmware version. * + * Status: In use [06.20.05/06.21.00 - LATEST] */ + ModemFirmwareVersion firmware_version = 4; + + /* Modem identification: * + * - IMEI (International Mobile Equipment Identity) of the modem * + * - Serial number of the most recently used modem-sharing sensor * + * String up to 15 characters. * + * Status: In use [07.00.00 - LATEST] */ + string modem_identification = 5; + + /* Modem statistics. * + * 1st item - Number of transmissions since the last device information message. Undefined value: 0. * + * 2nd item - Time in seconds since the last device information message. Undefined value: 0. * + * 3rd item - Total time in the power saving mode, in seconds. Undefined value: 0. * + * 4th item - Total time in the active state in seconds. Undefined value: 0. * + * Status: In use [07.00.00 - LATEST] */ + repeated uint32 modem_statistics = 6; } -message ProtoUpdateInfo +message ProtoDeviceInfo { - /* Timestamp of update (Unix timestamp) */ - uint32 timestamp = 1; + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ - /* Update status, possible values: */ - /* - 1 - No update yet */ - /* - 2 - No error */ - /* - 3 - UDP socekt error */ - /* - 4 - Hash error */ - /* - 5 - Missing packet error */ - /* - 6 - Invalid data error */ - /* - 7 - Sending timeout error */ - /* - 8 - No SW to update error */ - /* - 9 - Sending unexpected error */ - /* - 10 - Unexpected error */ - uint32 status = 2; -} + reserved 2,4,5,6,7,8,9,10,11,12; -message ProtoDeviceInfo -{ + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ - /* Serial number of device */ - bytes serial_num = 1; + /* Serial number of the device. * + * Length: 6 bytes. * + * Status: In use [07.00.00 - LATEST] / Previously as serial_num [06.00 - 06.xx.xx] */ + bytes serial_number = 1; - /* Deprecated field */ - reserved 2; + /* DEVICE INFORMATION FIELDS ------------------------------------------------------------------------------------------------ */ - /* Software version e.g ver 06.10 -> 0x060A -> 1546 */ + /* Software version (excluding LTS). * + * Example: 1546 -> 0x060A -> SW version 06.10 * + * Status: In use [06.00 - LATEST] */ uint32 sw_version = 3; - /* Deprecated fields */ - reserved 4,5,6,7,8,9,10,11,12; + /* Software commit ID and LTS version. * + * String of 7 characters: * + * - Commit ID (examples: "fa02cd0" means the beginning of the commit ID "fa02cd0") [06.00 - 06.06.xx] * + * - LTS version and commit ID (examples: "0bdd23f" means LTS version 11 and the beginning of the commit ID "dd23f") * + [06.07.00 - LATEST] * + * Status: In use [06.00 - LATEST] */ + string commit_id = 15; - /* Structure with battery and temperature information */ + /* Device runtime information. * + * Status: In use [06.00 - LATEST] */ ProtoRuntime runtime_info = 13; - /* Structure with modem specific runtime information */ + /* Memory statistics: * + * 1st item - Status of the non-volatile storage: * + * - 0 - Non-volatile storage works properly * + * - 1 - Non-volatile storage has some corrupt packets. Memory is read-only * + * - 2 - Non-volatile storage is corrupted. Memory is unavailable * + * 2nd item - Timestamp of the end of collecting statistics in seconds since 1st of January 1970 (epoch time). * + Undefined value: 4294967295. * + * 3rd item - Capacity of the memory in bytes. * + * 4th item - Used space in bytes. * + * 5th item - Size of invalid (outdated) packets in bytes. * + * 6th item - Size of corrupt packets in bytes. * + * 7th item - Number of valid packets. * + * 8th item - Number of invalid (outdated) packets. * + * 9th item - Number of corrupt packets. * + * 10th item - Number of all samples for channel 1 (valid packets). * + * 11th item - Number of all samples for channel 2 (valid packets). * + * 12th item - Number of all samples for channel 3 (valid packets). * + * 13th item - Number of all samples for channel 4 (valid packets). * + * 14th item - Number of all samples for channel 5 (valid packets). * + * 15th item - Number of all samples for channel 6 (valid packets). * + * 16th item - Timestamp of the first binary measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 17th item - Timestamp of the last binary measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 18th item - Timestamp of the last binary measurement marked as sent, in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 19th item - Timestamp of the first continuous measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 20th item - Timestamp of the last continuous measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 21th item - Timestamp of the last continuous measurement marked as sent, in seconds since 1st of January 1970 (epoch time).* + * Undefined value: 4294967295. * + * 22th item - NvM write counter. * + * Status: In use [06.02 - LATEST] */ + repeated uint32 memory_statistics = 17; + + /* Last update check information. * + * Status: In use [06.08.00 - LATEST] */ + ProtoUpdateInfo last_update_info = 18; + + /* Modem information. * + * Only used if the sensor has a modem. * + * Status: In use [06.00 - LATEST] */ ProtoModem modem = 14; - /* String up to 7 bytes long. Software commit id e.g. "e0e8556" */ - /* From version 06.07 the first two characters indicate the LTS version. */ - /* For example: the value "0bdd23f" means LTS version 11 and the beginning of the commit ID "dd23f" */ - string commit_id = 15; + /* SERVER COMMUNICATION FIELDS ---------------------------------------------------------------------------------------------- */ - /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ + /* Cloud token. * + * Can be empty, set to any user-defined value, or hold device IMEI. * + * String up to 36 characters. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ string cloud_token = 16; - /* Memory statistics: */ - /* memory_statistics[0] - Status of Nv storage: */ - /* - 0 - Nv storage hasn't errors */ - /* - 1 - Nv storage has some corrupted packet. Memory is read-only */ - /* - 2 - Nv storage is corrupted. Memory is unavailable */ - /* memory_statistics[1] - Timestamp of the end of collecting statistics. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[2] - Capacity of memory in bytes */ - /* memory_statistics[3] - Used space in bytes */ - /* memory_statistics[4] - Size of invalid (outdated) packets in bytes */ - /* memory_statistics[5] - Size of corrupted packets in bytes */ - /* memory_statistics[6] - Number of valid packets */ - /* memory_statistics[7] - Number of invalid (outdated) packets */ - /* memory_statistics[8] - Number of corrupted packets */ - /* memory_statistics[9] - Number of all samples for channel 1 (valid packets) */ - /* memory_statistics[10] - Number of all samples for channel 2 (valid packets) */ - /* memory_statistics[11] - Number of all samples for channel 3 (valid packets) */ - /* memory_statistics[12] - Number of all samples for channel 4 (valid packets) */ - /* memory_statistics[13] - Number of all samples for channel 5 (valid packets) */ - /* memory_statistics[14] - Number of all samples for channel 6 (valid packets) */ - /* memory_statistics[15] - Timestamp of the first binary measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[16] - Timestamp of the last binary measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[17] - Timestamp of the last binary measurement, that marked as sent. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[18] - Timestamp of the first continuous measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[19] - Timestamp of the last continuous measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[20] - Timestamp of the last continuous measurement, that marked as sent. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[21] - NVM write counter */ - repeated uint32 memory_statistics = 17; - - /* Information about last sensor SW update */ - ProtoUpdateInfo last_update_info = 18; + /* Enclosure tamper alert: * + * - 0 - Inactive * + * - 1 - Active (enclosure opened) * + * - 2 - Inactive, was active (enclosure closed) * + * The transition from 'Inactive, was active' to 'Inactive' occurs after the alert has been successfully sent to the server * + * or the user has reset the alert via Bluetooth. * + * This field is only sent during server communication. * + * Status: In use [07.00.01 - LATEST] */ + uint32 enclosure_tamper_alert = 19; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto index fc72cc10e3..7e7e908e16 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto @@ -19,160 +19,201 @@ option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "MeasurementTypeProtos"; enum MeasurementType { - /* [] - No sensor on the channel */ + + /* [] - No sensor on the channel. */ MEASUREMENT_TYPE_NO_SENSOR = 0; - /* [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2:4000.0]. Type: Continuous */ + /* [°C] - Celsius degree. Temperature. Resolution: 0.1°C. Range: [-273.2:4000.0]. Type: Continuous. */ MEASUREMENT_TYPE_TEMPERATURE = 1; - /* [% RH] - Relative humidity. Resolution 1%. Range [0:100]. Type: Continuous */ + /* [% RH] - Percentage. Relative humidity. Resolution: 1%. Range: [0:100]. Type: Continuous. */ MEASUREMENT_TYPE_HUMIDITY = 2; - /* [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0:2000.0]. Atmospheric pressure. Type: Continuous */ + /* [hPa] - Hectopascal. Atmospheric pressure. Resolution: 0.1hPa. Range: [1.0:2000.0]. Type: Continuous. */ MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE = 3; - /* [Pa] - Pascal. Resolution 1Pa. Range [-10000:10000]. Differential pressure. Type: Continuous */ + /* [Pa] - Pascal. Differential pressure. Resolution: 1Pa. Range: [-10000:10000]. Type: Continuous. */ MEASUREMENT_TYPE_DIFFERENTIAL_PRESSURE = 4; - /* Sign indicates state: (+) ALARM, (-) OK. Type: Binary */ + /* Sign indicates state: (+) Alarm, (-) OK. Type: Binary. */ MEASUREMENT_TYPE_OK_ALARM = 5; - /* [IAQ] - IAQ index. Resolution 1IAQ. Range [0:500]. To get IAQ index the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [IAQ] - IAQ index. Air quality. Resolution: 1IAQ. Range: [0:500]. Type: Continuous. * + * To obtain the IAQ index, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_IAQ = 6; - /* Sign indicates water presence: (+) water not detected, (-) water detected. Type: Binary */ + /* Sign indicates state: (+) Water detected, (-) Water not detected. Type: Binary. */ MEASUREMENT_TYPE_FLOODING = 7; - /* [NB] Number of pulses. Resolution 1 pulse. Range [0:8000000]. Type: Continuous */ + /* [NB] - Number of pulses. Number of pulses in a single period. Resolution: 1 pulse. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_PULSE_CNT = 8; - /* [Wh] - Watthour; Resolution 1Wh. Range [0:8000000]. Number of Watthours in a single period. Type: Continuous */ + /* [Wh] - Watt-hour. Number of watt-hours in a single period. Resolution: 1Wh. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_ELECTRICITY_METER = 9; - /* [l] - Liter. Resolution 1l. Range [0:8000000]. Number of litres in a single period. Type: Continuous */ + /* [l] - Liter. Number of litres in a single period. Resolution: 1l. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_WATER_METER = 10; - /* [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000:0]. Soil moisture (tension). Type: Continuous */ + /* [kPa] - Kilopascal. Soil moisture (tension). Resolution: 1kPa. Range: [-1000:0]. Type: Continuous. */ MEASUREMENT_TYPE_SOIL_MOISTURE = 11; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon monoxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Carbon monoxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_CO_GAS = 12; - /* [ppm] - Parts per million. Resolution 1.0ppm. Range [0:1000000]. Nitrogen dioxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Nitrogen dioxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_NO2_GAS = 13; - /* [ppm] - Parts per million. Resolution 0.01ppm. Range [0.00:80000.00]. Hydrogen sulfide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Hydrogen sulfide concentration. Resolution: 0.01ppm. Range: [0.00:80000.00]. Type: Continuous. */ MEASUREMENT_TYPE_H2S_GAS = 14; - /* [lx] - Lux. Resolution 0.1lx. Range [0.0:100000.0]. Illuminance. Type: Continuous */ + /* [lx] - Lux. Illuminance. Resolution: 0.1lx. Range: [0.0:100000.0]. Type: Continuous. */ MEASUREMENT_TYPE_AMBIENT_LIGHT = 15; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 1 micrometer. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 1 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_1_0 = 16; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 2.5 micrometers. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 2.5 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_2_5 = 17; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 10 micrometers. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 10 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_10_0 = 18; - /* [dB] - Decibels. Resolution 0.1 dB. Range: [0.0:200.0]. Noise level. Type: Continuous */ + /* [dB] - Decibel. Noise level. Resolution: 0.1 dB. Range: [0.0:200.0]. Type: Continuous. */ MEASUREMENT_TYPE_NOISE_LEVEL = 19; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Ammonia concentration. Type: Continuous */ + /* [ppm] - Parts per million. Ammonia concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_NH3_GAS = 20; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Methane concentration. Type: Continuous */ + /* [ppm] - Parts per million. Methane concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_CH4_GAS = 21; - /* [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [0:200000]. Pressure. Type: Continuous */ + /* [kPa] - Kilopascal (100kPa = 1bar). Pressure. Resolution: 1kPa. Range: [0:200000]. Type: Continuous. */ MEASUREMENT_TYPE_HIGH_PRESSURE = 22; - /* [mm] - Millimeter. Resolution 1mm. Range [0:100000]. Distance. Type: Continuous */ + /* [mm] - Millimeter. Distance. Resolution: 1mm. Range: [0:100000]. Type: Continuous. */ MEASUREMENT_TYPE_DISTANCE_MM = 23; - /* [l] - Liter. Resolution 1l. Range [0:1000000]. Accumulative water meter (minor). Type: Continuous */ + /* [l] - Liter. Water meter (minor). Resolution: 1l. Range: [0:99]. Type: Continuous. * + * To obtain the liters, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_WATER_METER_ACC_MINOR = 24; - /* [hl] - Hectoliter. Resolution 1hl. Range [0:1000000]. Accumulative water meter (major). Type: Continuous */ + /* [hl] - Hectoliter. Water meter (major). Resolution: 1hl. Range: [0:999999]. Type: Continuous. * + * To obtain the hectoliters, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_WATER_METER_ACC_MAJOR = 25; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Carbon dioxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. * + * To obtain the carbon dioxide concentration, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Auto calibration has not yet been performed * + * - 1 - The last auto calibration was successful * + * - 2 - Last auto calibration failed */ MEASUREMENT_TYPE_CO2_GAS = 26; - /* [% RH] - Relative humidity. Resolution 0.1%. Range [0.0:100.0]. Type: Continuous */ + /* [% RH] - Percentage. Relative humidity (accurate). Resolution: 0.1%. Range: [0.0:100.0]. Type: Continuous. */ MEASUREMENT_TYPE_HUMIDITY_ACCURATE = 27; - /* [sIAQ] - Static IAQ index. Resolution 1IAQ. Range [0:10000]. To get static IAQ index the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [sIAQ] - Static IAQ index. Air quality. Resolution: 1sIAQ. Range: [0:10000]. Type: Continuous. * + * To obtain the static IAQ index, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_STATIC_IAQ = 28; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. CO2 equivalent. */ - /* To get CO2 equivalent the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [ppm] - Parts per million. CO2 equivalent. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. * + * To obtain the CO2 equivalent, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_CO2_EQUIVALENT = 29; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ - /* To get breath VOC estimate the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [ppm] - Parts per million. Breath VOC estimate. Resolution: 1ppm. Range: [0:100000]. Type: Continuous. * + * To obtain the breath VOC estimate, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_BREATH_VOC = 30; - /* Special measurement type reserved for cellular gateway. */ - /* Type: Continuous */ - MEASUREMENT_TYPE_CELLULAR_GATEWAY = 31; + /* Reserved for the Shared Modem functionality. Type: Continuous. */ + MEASUREMENT_TYPE_SHARED_MODEM = 31; - /* [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. Type: Continuous */ + /* [%] - Percentage. Generic type. Resolution: 0.01%. Range: [0.00:100.00]. Type: Continuous. */ MEASUREMENT_TYPE_PERCENTAGE = 32; - /* [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. Type: Continuous */ + /* [mV] - Millivolt. Voltage. Resolution: 0.1mV. Range: [0.0:100000.0]. Type: Continuous. */ MEASUREMENT_TYPE_VOLTAGE = 33; - /* [mA] - Milliampere. Resolution 0.01mA. Range [0.0:10000.00]. Type: Continuous */ + /* [mA] - Milliampere. Current. Resolution: 0.01mA. Range: [0.00:10000.00]. Type: Continuous. */ MEASUREMENT_TYPE_CURRENT = 34; - /* [NB] Number of pulses. Resolution 1 pulse. Range [0:1000000]. Type: Continuous */ + /* [NB] - Number of pulses. Pulse counter (minor). Resolution: 1 pulse. Range: [0:999]. Type: Continuous. * + * To obtain the number of pulses, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_PULSE_CNT_ACC_MINOR = 35; - /* [kNB] Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. Type: Continuous */ + /* [kNB] - Number of kilopulses. Pulse counter (major). Resolution: 1 kilopulse. Range: [0:999999]. Type: Continuous. * + * To obtain the number of kilopulses, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_PULSE_CNT_ACC_MAJOR = 36; - /* [Wh] - Watt-hour; Resolution 1Wh. Range [0:1000000]. Number of watt-hours in a single period. Type: Continuous */ + /* [Wh] - Watt-hour. Electricity meter (minor). Resolution: 1Wh. Range: [0:999]. Type: Continuous. * + * To obtain the watt-hours, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_ELEC_METER_ACC_MINOR = 37; - /* [kWh] - Kilowatt-hour; Resolution 1kWh. Range [0:1000000]. Number of kilowatt-hours in a single period. Type: Continuous */ + /* [kWh] - Kilowatt-hour. Electricity meter (major). Resolution: 1kWh. Range: [0:999999]. Type: Continuous. * + * To obtain the kilowatt-hours, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_ELEC_METER_ACC_MAJOR = 38; - /* [NB] Number of pulses (wide range). Resolution 1 pulse. Range [0:999999]. Type: Continuous */ + /* [NB] - Number of pulses. Wide-range pulse counter (minor). Resolution: 1 pulse. Range: [0:999999]. Type: Continuous. * + * To obtain the number of pulses, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MINOR = 39; - /* [MNB] Number of megapulses (wide range). Resolution 1 megapulse. Range [0:999999]. Type: Continuous */ + /* [MNB] - Number of megapulses. Wide-range pulse counter (major). Resolution: 1 megapulse. Range: [0:999999]. * + * Type: Continuous. * + * To obtain the number of megapulses, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MAJOR = 40; - /* [mA] - Milliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. Type: Continuous */ + /* [mA] - Milliampere. Current (precise). Resolution: 0.001mA. Range: [-4000.000:4000.000]. Type: Continuous. */ MEASUREMENT_TYPE_CURRENT_PRECISE = 41; - /* Sign indicates state: (+) ON, (-) OFF. Type: Binary */ + /* Sign indicates state: (+) ON, (-) OFF. Type: Binary. */ MEASUREMENT_TYPE_OUTPUT_CONTROL = 42; -} - + /* [Ω] - Ohm. Resolution: 1Ω. Range: [0:1000000]. Type: Continuous. */ + MEASUREMENT_TYPE_RESISTANCE = 43; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_measurements.proto b/common/transport/coap/src/main/proto/efento/proto_measurements.proto index 650487ff29..12626d4834 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurements.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurements.proto @@ -14,6 +14,7 @@ * limitations under the License. */ syntax = "proto3"; + import "efento/proto_measurement_types.proto"; option java_package = "org.thingsboard.server.gen.transport.coap"; @@ -21,106 +22,166 @@ option java_outer_classname = "MeasurementsProtos"; message ProtoChannel { - /* Type of channel */ + /* Reserved fields. */ + reserved 6,7,8; + + /* Type of measurement. * + * Status: In use [06.00 - LATEST] */ MeasurementType type = 1; - /* Timestamp of the first sample (the oldest one) in seconds since UNIX EPOCH 01-01-1970 */ + /* Timestamp of the first (oldest) sample in seconds since 1st of January 1970 (epoch time). * + * Status: In use [06.00 - LATEST] */ int32 timestamp = 2; - /* Only used for 'Continuous' sensor types. Value used as the starting point for calculating the values of all */ - /* measurements in the package. */ - /* Format defined by 'MeasurementType' field */ + /* Start point of measurement values. * + * This is used as the base value for calculating the values of all channel measurements in the message. * + * Used for 'Continuous' sensor types only. * + * Format is defined by 'MeasurementType' field. * + * Status: In use [06.00 - LATEST] */ sint32 start_point = 4; - /* 'Continuous' sensor types */ - /* Value of the offset from the 'start_point' for each measurement in the package. The oldest sample first ([0]). */ - /* 'sample_offsets' format defined by 'MeasurementType' field. */ - /* If the 'sample_offset' has a value from the range [8355840: 8388607], it should be interpreted as a sensor error code. */ - /* In that case value of the 'start_point' field should not be added to this 'sample_offset'. See ES6-264 for error codes. */ - /* Example: MeasurementType = 1 (temperature), start_point = 100, sample_offsets[0] = 15, sample_offsets[1] = 20, */ - /* sample_offset[2] = 8388605 */ - /* 1st sample in the package temperature value = 11.5 °C, 2nd sample in the package temperature value = 12 °C */ - /* 3rd sample in the package has no temperature value. It has information about failure of MCP9808 (temperature) sensor. */ - /* Calculating timestamps of the measurements: timestamp = 1606391700, measurement_period_base = 60, */ - /* measurement_period_factor = 1. Timestamp of the 1st sample = 1606391700, timestamp of the 2nd sample = 1606391760, */ - /* timestamp of the 3rd sample 1606391820 */ - - /* 'Binary' sensor types: */ - /* Absolute value of the 'sample_offsets' field indicates the offset in seconds from 'timestamp' field. */ - /* Sign (- or +) indicates the state of measurements depending on the sensor type. */ - /* Value of this field equals to '1' or '-1' indicates the state at the 'timestamp'. Other values */ - /* indicate the state of the relay at the time (in seconds) equal to 'timestamp' + absolute value -1. */ - /* Values of this field are incremented starting from 1 (1->0: state at the time */ - /* of 'timestamp', 2->1: state at the time equal to 'timestamp' + 1 s, 3->2 : */ - /* state at the time equal to 'timestamp' + 2 s, etc.). The first and the last sample define the time range of the */ - /* measurements. Only state changes in the time range are included in the 'sample_offsets' field */ - /* Examples: if 'timestamp' value is 1553518060 and 'sample_offsets' equals '1', it means that at 1553518060 the state */ - /* was high, if 'timestamp' value is 1553518060 and 'sample_offsets' equals '-9', it means at 1553518068 the state was low */ + /* Measurement value offsets. * + * For 'Continuous' sensor types: * + * These are the values of the offsets from the 'start_point' for each channel measurement in the message. * + * Format is defined by 'MeasurementType' field. The first sample is the oldest. * + * If the sample offset has a value in the range [8355840:8388607], it should be interpreted as a sensor error code. * + * In this case, the value of the 'start_point' field should not be added to this sample offset. * + * Example: type = 1 (temperature) * + * start_point = 100 * + * sample_offsets[0] = 15, sample_offsets[1] = 20, sample_offsets[2] = 8388557 * + * timestamp = 1606391700 * + * measurement_period_base = 60 * + * measurement_period_factor = 1 * + * 1st measurement is the temperature value = 11.5°C, measured at 1606391700 * + * 2nd measurement is the temperature value = 12°C, measured at 1606391760 * + * 3rd measurement is the error code (failure of the SHT4x temperature sensor), measured at 1606391820 * + * For 'Binary' sensor types: * + * The absolute value of the sample offset minus 1 is the offset in seconds from the 'timestamp' field. * + * Sign (- or +) indicates the state of the measurement depending on the sensor type. * + * The first and the last samples define the time range of the measurements. * + * Only state changes within the time range are included in the 'sample_offsets' field. * + * Example: type = 5 (OK/Alarm) * + * sample_offsets[0] = 1, sample_offsets[1] = -9, sample_offsets[2] = 13 * + * timestamp = 1606391700 * + * 1st measurement is the state at 1606391700, which is Alarm (+) * + * 2nd measurement is the state at 1606391708, which is OK (-) * + * 3rd measurement is the state at 1606391712, which is Alarm (+) * + * Status: In use [06.00 - LATEST] */ repeated sint32 sample_offsets = 5 [packed=true]; - - /* Deprecated - configuration is sent to endpoint 'c' */ - /* int32 lo_threshold = 6; */ - reserved 6; - - /* Deprecated - configuration is sent to endpoint 'c' */ - /* int32 hi_threshold = 7; */ - reserved 7; - - /* Deprecated - configurations sent to endpoint 'c' */ - /* int32 diff_threshold = 8; */ - reserved 8; } message ProtoMeasurements { - /* Serial number of the device */ - bytes serial_num = 1; - - /* Battery status: true - battery ok, false - battery low */ - bool battery_status = 2; - - /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ - /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ - /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ - /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ - /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ - /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ - /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ - /* measurement period base in seconds */ + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ + + reserved 10,11,12,13,14,15; + + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ + + /* Serial number of the device. * + * Length: 6 bytes. * + * Status: In use [07.00.00 - LATEST] / Previously as serial_num [06.00 - 06.xx.xx] */ + bytes serial_number = 1; + + /* Identifier of the current configuration. * + * The value of this field changes with every configuration change. * + * Status: In use [07.00.00 - LATEST] / Previously as hash [06.00 - 06.xx.xx] */ + uint32 configuration_hash = 9; + + /* Identifier of the current extended configuration. * + * The value of this field changes with every extended configuration change. * + * Status: In use [07.00.00 - LATEST] */ + uint32 extended_configuration_hash = 17; + + /* Battery level and status: * + * - [1:65525] - Battery voltage measurement and a battery status * + * - [65526:65531] - No battery voltage measurement and a battery status * + * To obtain the battery voltage, the battery_level value should be divided by 6 and the remainder of the division discarded. * + * The result is in hundredths of a volt or 10921 (no battery voltage measurement). * + * Battery level also includes a battery status as metadata (the remainder of the value divided by 6): * + * - 0 - Device is powered by a battery * + * - 1 - Device is powered by a rechargeable battery * + * - 2 - Device is powered by an external source and a battery is charging * + * - 3 - Reserved for the future use * + * - 4 - Reserved for the future use * + * - 5 - Reserved for the future use * + * Example: battery_level = 2474 * + * Battery voltage = 2474/6 = 412 = 4.12[V] * + * Battery status = 2474 % 6 = 2 * + * Status: In use [07.00.00 - LATEST] */ + uint32 battery_level = 18; + + /* MEASUREMENT FIELDS ------------------------------------------------------------------------------------------------------- */ + + /* Measurement period defines how often the measurements are to be taken. * + * Sensors of 'Continuous' type take measurement each 'measurement_period_base' * 'measurement_period_factor'. * + * Sensors of 'Binary' type take measurement each 'measurement_period_base'. * + * For backward compatibility with versions 5.xx in case of 'Binary/Mixed' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '14' shall be used for the period calculation. * + * For backward compatibility with versions 5.xx in case of 'Continuous' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '1' shall be used for the period calculation. */ + + /* Measurement period base in seconds. * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_base = 3; - /* Measurement period factor */ + /* Measurement period factor. * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_factor = 8; + /* Measurements grouped by channel. * + * Status: In use [06.00 - LATEST] */ repeated ProtoChannel channels = 4; - /* Timestamp of the next scheduled transmission. If the device will not send data until this time, */ - /* it should be considered as 'lost' */ + /* SERVER COMMUNICATION FIELDS ---------------------------------------------------------------------------------------------- */ + + /* Battery status: * + * - True - Battery OK * + * - False - Battery discharged * + * Status: In use [06.00 - LATEST] */ + bool battery_status = 2; + + /* Timestamp of the next scheduled transmission. * + * If the device has not sent any data by this time, it should be considered 'lost'. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ uint32 next_transmission_at = 5; - /* Reason of transmission - unsigned integer where each bit indicates different possible communication reason. */ - /* Can be more than one: */ - /* - bit 0: first message after sensor reset */ - /* - bit 1: user button triggered */ - /* - bit 2: user BLE triggered */ - /* - bit 3-7: number of retries -> incremented after each unsuccessful transmission. Max value 31. */ - /* Set to 0 after a successful transmission. */ - /* - bit 8...19: rule 1...12 was met */ - /* - bit 20: triggered after the end of the limit */ + /* Reason for transmission. * + * Bitmask, where each bit represents a specific reason for initiating communication (multiple reasons can be active * + * simultaneously): * + * - Bit 0 - First message after sensor reset * + * - Bit 1 - User-triggered (button press) * + * - Bit 2 - User-triggered (Bluetooth) * + * - Bit 3:7 - Number of retries - incremented after each failed transmission and reset upon success. Range: [0:31] * + * - Bit 8:19 - Rule-based triggers (12 bits): * + * - [06.00 - 07.01.xx]: Bitmask indicating which of the rules 1-12 were met * + * - [07.02.00 - LATEST]: The transfer bit 'i' (where i = 0 to 11) is set if either rule 'i' (0-11) is set * + * or if rule 'i + 12' (12-15) is set. This aggregation applies only to bits 0-3 (rules 0-3 and 12-15). * + * The mask aggregates 16 rules (0-15) onto 12 bits. * + * - Bit 20 - Triggered at the end of the defined limit * + * - Bit 21 - Triggered after a PIN tamper alert * + * - Bit 22 - Triggered after an enclosure tamper alert * + * - Bit 23 - Triggered before shutdown * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ uint32 transfer_reason = 6; - /* Signal strength level mapped from RSSI: */ - /* - 0: RSSI < -110 dBm */ - /* - 1: -110 dBm <= RSSI < -109 dBm */ - /* - 2...61: -109 <= RSSI < -108 dBm ... -50 dBm <= RSSI < -49 dBm */ - /* - 62: -49 dBm <= RSSI < -48 dBm */ - /* - 63: RSSI >= -48 dBm */ - /* - 99: Not known or not detectable */ + /* Signal strength (RSSI - Received Signal Strength Level): * + * - 0 - RSSI < -110dBm * + * - 1 - -110dBm <= RSSI < -109dBm * + * - 2:61 - -109dBm <= RSSI < -108dBm ... -50dBm <= RSSI < -49dBm * + * - 62 - -49dBm <= RSSI < -48dBm * + * - 63 - RSSI >= -48dBm * + * - 99 - Unknown or undetectable * + * This field is only sent during server communication. * + * Status: Deprecated [06.00 - 06.xx.xx] */ uint32 signal = 7; - /* Hash of the current configuration. Hash value changes each time a device receives a new configuration */ - uint32 hash = 9; - - /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ + /* Cloud token. * + * Can be empty, set to any user-defined value, or hold device IMEI. * + * String up to 36 characters. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ string cloud_token = 16; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_rule.proto b/common/transport/coap/src/main/proto/efento/proto_rule.proto index 7473d9b52a..5d91712864 100644 --- a/common/transport/coap/src/main/proto/efento/proto_rule.proto +++ b/common/transport/coap/src/main/proto/efento/proto_rule.proto @@ -18,270 +18,287 @@ syntax = "proto3"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "ProtoRuleProtos"; -/* Encoding A: used to set absolute values in the Rules (e.g. upper and lower threshold values) */ -/* - TEMPERATURE - [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2:4000.0]. */ -/* - HUMIDITY - [% RH] - Relative humidity. Resolution 1%. Range [0:100]. */ -/* - ATMOSPHERIC_PRESSURE - [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0:2000.0]. */ -/* - DIFERENTIAL_PRESSURE - [Pa] - Pascal. Resolution 1Pa. Range [-10000:10000] */ -/* - OK/ALARM - Not applicable */ -/* - IAQ - [IAQ] - IAQ index. Resolution 1IAQ. Range [0:500]. */ -/* - FLOODING - Not applicable */ -/* - PULSE_CNT - [NB] Number of pulses. Resolution 1 pulse. Range [0:8000000]. */ -/* - ELECTRICITY_METER - [W] - Watt; Resolution 1W. Range [0:8000000]. Average power consumption in period */ -/* - WATER_METER [l/min] - Liter per minute. Resolution 1l/min. Range [0:8000000]. Average water flow in period. */ -/* - SOIL_MOISTURE - [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000:0]. Soil moisture (tension). */ -/* - CO_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon monoxide concentration. */ -/* - NO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Nitrogen dioxide concentration. */ -/* - H2S_GAS - [ppm] - Parts per million. Resolution 0.01ppm. Range [0.00:80000.00]. Hydrogen sulfide concentration. */ -/* - AMBIENT_LIGHT -[lx] - Lux. Resolution 0.1lx. Range [0.0:100000.0]. Illuminance. */ -/* - PM_1_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - PM_2_5 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - PM_10_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - NOISE_LEVEL - [dB] - Decibels. Resolution 0.1 dB. Range: [0.0:200.0]. Noise level. */ -/* - NH3_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Ammonia concentration. */ -/* - CH4_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Methane concentration. */ -/* - HIGH_PRESSURE - [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [0:200000]. Pressure. */ -/* - DISTANCE_MM - [mm] - Millimeter. Resolution 1mm. Range [0:100000]. Distance. */ -/* - WATER_METER_ACC_MINOR - [l] - Liter. Resolution 1l. Range [0:1000000]. Accumulative water meter (minor). */ -/* - WATER_METER_ACC_MAJOR - [hl] - Hectoliter. Resolution 1hl. Range [0:1000000]. Accumulative water meter (major). */ -/* - CO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide concentration. */ -/* - HUMIDITY ACCURATE - [% RH] - Relative humidity. Resolution 0.1%. Range [0.0:100.0]. */ -/* - STATIC_IAQ - [sIAQ] - Static IAQ index. Resolution 1sIAQ. Range [0:10000]. */ -/* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide equivalent. */ -/* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ -/* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. */ -/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. */ -/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.00:10000.00]. */ -/* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:1000000]. Accumulative pulse counter (minor). */ -/* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. */ -/* Accumulative pulse counter (major). */ -/* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [0:1000000]. Accumulative electricity meter (minor). */ -/* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [0:1000000]. Accumulative electricity meter (major). */ -/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:999999]. */ -/* Accumulative pulse counter wide range (minor). */ -/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [0:999999]. */ -/* Accumulative pulse counter wide range (major). */ -/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. */ -/* - OUTPUT_CONTROL - Not applicable */ - -/* Encoding R: used to set relative values in the Rules (e.g. differential threshold and hysteresis) */ -/* - TEMPERATURE - [°C] - Celsius degree. Resolution 0.1°C. Range [0.1:4273.2]. */ -/* - HUMIDITY - [% RH] - Relative humidity. Resolution 1%. Range [1:100]. */ -/* - ATMOSPHERIC_PRESSURE - [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [0.1:1999.0]. */ -/* - DIFERENTIAL_PRESSURE - [Pa] - Pascal. Resolution 1Pa. Range [1:20000] */ -/* - OK/ALARM - Not applicable */ -/* - VOC - [IAQ] - Iaq index. Resolution 1IAQ. Range [1:500]. */ -/* - FLOODING - Not applicable */ -/* - PULSE_CNT - [NB] Number of pulses. Resolution 1 pulse. Range [1:8000000]. */ -/* - ELECTRICITY_METER - [W] - Watt; Resolution 1W. Range [1:8000000]. Average power consumption in period */ -/* - WATER_METER [l/min] - Liter per minute. Resolution 1l/min. Range [1:8000000]. Average water flow in period. */ -/* - SOIL_MOISTURE - [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [1:1000]. Soil moisture (tension). */ -/* - CO_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon monoxide concentration. */ -/* - NO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Nitrogen dioxide concentration. */ -/* - H2S_GAS - [ppm] - Parts per million. Resolution 0.01ppm. Range [0.01:80000.00]. Hydrogen sulfide concentration. */ -/* - AMBIENT_LIGHT -[lx] - Lux. Resolution 0.1lx. Range [0.1:100000.0]. Illuminance. */ -/* - PM_1_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - PM_2_5 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - PM_10_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - NOISE_LEVEL - [dB] - Decibels. Resolution 0.1 dB. Range: [0.1:200.0]. Noise level. */ -/* - NH3_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Ammonia concentration. */ -/* - CH4_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Methane concentration. */ -/* - HIGH_PRESSURE - [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [1:200000]. Pressure. */ -/* - DISTANCE_MM - [mm] - Millimeter. Resolution 1mm. Range [1:100000]. Distance. */ -/* - WATER_METER_ACC_MINOR - [l] - Liter. Resolution 1l. Range [1:1000000]. Accumulative water meter (minor). */ -/* - WATER_METER_ACC_MAJOR - [hl] - Hectoliter. Resolution 1hl. Range [1:1000000]. Accumulative water meter (major). */ -/* - CO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon dioxide concentration. */ -/* - HUMIDITY ACCURATE - [% RH] - Relative humidity. Resolution 0.1%. Range [0.1:100.0]. */ -/* - STATIC_IAQ - [sIAQ] - Static IAQ index. Resolution 1sIAQ. Range [1:10000]. */ -/* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon dioxide equivalent. */ -/* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [1:100000]. Breath VOC estimate. */ -/* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.01:100.00]. */ -/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.1:100000.0]. */ -/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.01:10000.00]. */ -/* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:1000000]. Accumulative pulse counter (minor). */ -/* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [1:1000000]. */ -/* Accumulative pulse counter (major). */ -/* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [1:1000000]. Accumulative electricity meter (minor). */ -/* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [1:1000000]. Accumulative electricity meter (major). */ -/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:999999]. */ -/* Accumulative pulse counter wide range (minor). */ -/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [1:999999]. */ -/* Accumulative pulse counter wide range (major). */ -/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [0.001:8 000.000]. */ -/* - OUTPUT_CONTROL - Not applicable */ - -/* Condition to be checked by the device. If the condition is true, an action is triggered */ +/* Encoding A: used to set absolute values in rules (e.g., upper and lower thresholds): * + * - TEMPERATURE - [°C] Celsius degree. Resolution: 0.1°C. Range: [-273.2:4000.0] * + * - HUMIDITY - [% RH] Percentage (Relative humidity). Resolution: 1%. Range: [0:100] * + * - ATMOSPHERIC_PRESSURE - [hPa] Hectopascal. Resolution: 0.1hPa. Range: [1.0:2000.0] * + * - DIFERENTIAL_PRESSURE - [Pa] Pascal. Resolution: 1Pa. Range: [-10000:10000] * + * - OK_ALARM - Not applicable * + * - IAQ - [IAQ] IAQ index. Resolution: 1IAQ. Range: [0:500] * + * - FLOODING - Not applicable * + * - PULSE_CNT - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:8000000]. Period number of pulses * + * - ELECTRICITY_METER - [W] Watt. Resolution: 1W. Range: [0:8000000]. Period average power consumption * + * - WATER_METER - [l/min] Liter per minute. Resolution: 1l/min. Range: [0:8000000]. Period average water * + * - SOIL_MOISTURE - [kPa] Kilopascal. Resolution: 1kPa. Range: [-1000:0] * + * - CO_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - NO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - H2S_GAS - [ppm] Parts per million. Resolution: 0.01ppm. Range: [0.00:80000.00] * + * - AMBIENT_LIGHT - [lx] Lux. Resolution: 0.1lx. Range: [0.0:100000.0] * + * - PM_1_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - PM_2_5 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - PM_10_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - NOISE_LEVEL - [dB] Decibel. Resolution: 0.1 dB. Range: [0.0:200.0] * + * - NH3_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - CH4_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - HIGH_PRESSURE - [kPa] Kilopascal (100kPa = 1bar). Resolution: 1kPa. Range: [0:200000] * + * - DISTANCE_MM - [mm] Millimeter. Resolution: 1mm. Range: [0:100000] * + * - WATER_METER_ACC_MINOR - [l] Liter. Resolution: 1l. Range: [0:99] * + * - WATER_METER_ACC_MAJOR - [hl] Hectoliter. Resolution: 1hl. Range: [0:999999] * + * - CO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - HUMIDITY_ACCURATE - [% RH] Percentage (Relative humidity). Resolution: 0.1%. Range: [0.0:100.0] * + * - STATIC_IAQ - [sIAQ] Static IAQ index. Resolution: 1sIAQ. Range: [0:10000] * + * - CO2_EQUIVALENT - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - BREATH_VOC - [ppm] Parts per million. Resolution: 1ppm. Range: [0:100000] * + * - CELLULAR_GATEWAY - Not applicable * + * - PERCENTAGE - [%] Percentage. Resolution: 0.01%. Range: [0.00:100.00] * + * - VOLTAGE - [mV] Millivolt. Resolution: 0.1mV. Range: [0.0:100000.0] * + * - CURRENT - [mA] Milliampere. Resolution: 0.01mA. Range: [0.00:10000.00] * + * - PULSE_CNT_ACC_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:999] * + * - PULSE_CNT_ACC_MAJOR - [kNB] Number of kilopulses. Resolution: 1 kilopulse. Range: [0:999999] * + * - ELEC_METER_ACC_MINOR - [Wh] Watt-hour. Resolution: 1Wh. Range: [0:999] * + * - ELEC_METER_ACC_MAJOR - [kWh] Kilowatt-hour. Resolution: 1kWh. Range: [0:999999] * + * - PULSE_CNT_ACC_WIDE_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:999999] * + * - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] Number of megapulses. Resolution: 1 megapulse. Range: [0:999999] * + * - CURRENT_PRECISE - [mA] Milliampere. Resolution: 0.001mA. Range: [-4000.000:4000.000] * + * - OUTPUT_CONTROL - Not applicable * + * - RESISTANCE - [Ω] Ohm. Resolution: 1Ω. Range: [0:1000000] */ + +/* Encoding R: used to set relative values in rules (e.g., differential threshold and hysteresis): * + * - TEMPERATURE - [°C] Celsius degree. Resolution: 0.1°C. Range: [0.1:4273.2] * + * - HUMIDITY - [% RH] Percentage (Relative humidity). Resolution: 1%. Range: [1:100] * + * - ATMOSPHERIC_PRESSURE - [hPa] Hectopascal. Resolution: 0.1hPa. Range: [0.1:1999.0] * + * - DIFERENTIAL_PRESSURE - [Pa] Pascal. Resolution: 1Pa. Range: [1:20000] * + * - OK_ALARM - Not applicable * + * - IAQ - [IAQ] IAQ index. Resolution: 1IAQ. Range: [1:500] * + * - FLOODING - Not applicable * + * - PULSE_CNT - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:8000000]. Period number of pulses * + * - ELECTRICITY_METER - [W] Watt. Resolution: 1W. Range: [1:8000000]. Period average power consumption * + * - WATER_METER - [l/min] Liter per minute. Resolution: 1l/min. Range: [1:8000000]. Period average water * + * - SOIL_MOISTURE - [kPa] Kilopascal. Resolution: 1kPa. Range: [1:1000] * + * - CO_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - NO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - H2S_GAS - [ppm] Parts per million. Resolution: 0.01ppm. Range: [0.01:80000.00] * + * - AMBIENT_LIGHT - [lx] Lux. Resolution: 0.1lx. Range: [0.1:100000.0] * + * - PM_1_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - PM_2_5 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - PM_10_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - NOISE_LEVEL - [dB] Decibel. Resolution: 0.1 dB. Range: [0.1:200.0] * + * - NH3_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - CH4_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - HIGH_PRESSURE - [kPa] Kilopascal (100kPa = 1bar). Resolution: 1kPa. Range: [1:200000] * + * - DISTANCE_MM - [mm] Millimeter. Resolution: 1mm. Range: [1:100000] * + * - WATER_METER_ACC_MINOR - [l] Liter. Resolution: 1l. Range: [1:99] * + * - WATER_METER_ACC_MAJOR - [hl] Hectoliter. Resolution: 1hl. Range: [1:999999] * + * - CO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - HUMIDITY_ACCURATE - [% RH] Percentage (Relative humidity). Resolution: 0.1%. Range: [0.1:100.0] * + * - STATIC_IAQ - [sIAQ] Static IAQ index. Resolution: 1sIAQ. Range: [1:10000] * + * - CO2_EQUIVALENT - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - BREATH_VOC - [ppm] Parts per million. Resolution: 1ppm. Range: [1:100000] * + * - CELLULAR_GATEWAY - Not applicable * + * - PERCENTAGE - [%] Percentage. Resolution: 0.01%. Range: [0.01:100.00] * + * - VOLTAGE - [mV] Millivolt. Resolution: 0.1mV. Range: [0.1:100000.0] * + * - CURRENT - [mA] Milliampere. Resolution: 0.01mA. Range: [0.01:10000.00] * + * - PULSE_CNT_ACC_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:999] * + * - PULSE_CNT_ACC_MAJOR - [kNB] Number of kilopulses. Resolution: 1 kilopulse. Range: [1:999999] * + * - ELEC_METER_ACC_MINOR - [Wh] Watt-hour. Resolution: 1Wh. Range: [1:999] * + * - ELEC_METER_ACC_MAJOR - [kWh] Kilowatt-hour. Resolution: 1kWh. Range: [1:999999] * + * - PULSE_CNT_ACC_WIDE_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:999999] * + * - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] Number of megapulses. Resolution: 1 megapulse. Range: [1:999999] * + * - CURRENT_PRECISE - [mA] Milliampere. Resolution: 0.001mA. Range: [0.001:8000.000] * + * - OUTPUT_CONTROL - Not applicable * + * - RESISTANCE - [Ω] Ohm. Resolution: 1Ω. Range: [1:1000000] */ + +/* Condition to be checked by the device. If the condition is true, an action is triggered. */ enum Condition { - /* Invalid value */ + /* Invalid value. */ CONDITION_UNSPECIFIED = 0; - /* Threshold function for given rule_id is disabled */ + /* The rule is disabled. */ CONDITION_DISABLED = 1; - /* Upper threshold. Continuous sensors only. If the measurement (or average from a few measurements) is over the threshold, */ - /* an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding A" format. Must match channel type */ - /* parameter[1] - Hysteresis value in "Encoding R" format. Must much channel type. Set to "0" to disable */ - /* parameter[2] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[3] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample above threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[4] - Type of measurement (as described in MeasurementType). */ + /* Upper threshold. 'Continuous' sensors only. * + * If the measurement (or average from a few measurements) is over the threshold, an action is triggered. * + * parameter[0] - Threshold value in "Encoding A" format. Must match channel type. * + * parameter[1] - Hysteresis value in "Encoding R" format. Must match channel type. Set to "0" to disable. * + * parameter[2] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[3] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample above threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[4] - Type of measurement (as described in MeasurementType). */ CONDITION_HIGH_THRESHOLD = 2; - /* Lower threshold. Continuous sensors only. If the measurement (or average from a few measurements) is below the threshold, */ - /* an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding A" format. Must match channel type */ - /* parameter[1] - Hysteresis value in "Encoding R" format. Must much channel type. Set to "0" to disable */ - /* parameter[2] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[3] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample below threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[4] - Type of measurement (as described in MeasurementType). */ + /* Lower threshold. 'Continuous' sensors only. * + * If the measurement (or average from a few measurements) is below the threshold, an action is triggered. * + * parameter[0] - Threshold value in "Encoding A" format. Must match channel type. * + * parameter[1] - Hysteresis value in "Encoding R" format. Must match channel type. Set to "0" to disable. * + * parameter[2] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[3] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample below threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[4] - Type of measurement (as described in MeasurementType). */ CONDITION_LOW_THRESHOLD = 3; - /* Differential threshold. Continuous sensors only. If the absolute value of the difference between the last value sent to */ - /* the server and the measurement value (or average from a few measurements) is greater or equal to the value of */ - /* the threshold set, an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding R" format. Must match channel type */ - /* parameter[1] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[2] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample exceeding threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[3] - Type of measurement (as described in MeasurementType). */ + /* Differential threshold. 'Continuous' sensors only. * + * If the absolute value of the difference between the last value sent to the server and the measurement value * + * (or average from a few measurements) is greater or equal to the value of the threshold set, an action is triggered. * + * parameter[0] - Threshold value in "Encoding R" format. Must match channel type. * + * parameter[1] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[2] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample exceeding threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[3] - Type of measurement (as described in MeasurementType). */ CONDITION_DIFF_THRESHOLD = 4; - /* Change of binary sensor's state. Binary sensors only. Each change of the binary's sensor state will trigger an action. */ + /* Change of binary sensor's state. 'Binary' sensors only. * + * Each change of the binary sensor's state will trigger an action. */ CONDITION_BINARY_CHANGE_STATE = 5; - /* Logic operator. Used for combining multiple rules into more complex conditions. If the logic condition specified by */ - /* parameters (logic operator and selected rules) is met, an action is triggered. */ - /* parameter[0] - Logic operator (as described in LogicOperation). */ - /* parameter[1] - Rule selector (bit mask). Specifies which rules should be taken into account while determining */ - /* rules outcome. */ - /* parameter[2] - Rule negation (bit mask). Specifies which of chosen in parameter[1] rules should be negated */ - /* before determining rules outcome. */ - /* parameter[3] - Rule action delay [s]. Specifies time delay between the rule activation and rule action being triggered. */ - /* Range: [0:864000]. */ - /* parameter[4] - Rule return delay [s]. Specifies time delay between the rule deactivation and rule action being triggered. */ - /* Range: [0:864001]. Max parameter value disables action triggering on rule deactivation. */ + /* Logic operator. Used for combining multiple rules into more complex conditions. * + * If the logic condition specified by parameters (logic operator and selected rules) is met, an action is triggered. * + * parameter[0] - Logic operator (as described in LogicOperator). * + * parameter[1] - Rule selector (bitmask). Specifies which rules should be taken into account while determining * + * rules outcome. * + * parameter[2] - Rule negation (bitmask). Specifies which of chosen in parameter[1] rules should be negated * + * before determining rules outcome. * + * parameter[3] - Rule action delay [s]. Specifies time delay between the rule activation and rule action being triggered. * + * Range: [0:864000]. * + * parameter[4] - Rule return delay [s]. Specifies time delay between the rule deactivation and rule action being triggered. * + * Range: [0:864001]. Max parameter value disables action triggering on rule deactivation. */ CONDITION_LOGIC_OPERATOR = 6; - /* On measurement. Continous sensors only. The basic function is to trigger communication after measurement if at least 60s */ - /* have passed since the last one. Transmission may occur every x measurement. Optionally dependency on the other rule can */ - /* be configured, then, when all conditions are met, transmission is triggered. */ - /* parameter[0] - Send every n measurement. This parameter specifies every which measurement transmission will be triggered */ - /* if all other conditions are met. Range: [1:500]. If parameter[0] equals 1, transmission will occur after */ - /* every measurement. */ - /* parameter[1] - Optional. Rule selector (bit mask). Specifies which rule should be taken into account while determining */ - /* the measurement rule outcome. */ - /* parameter[2] - Optional. Rule negation (bit mask). Specifies which of chosen in parameter[1] rule should be negated */ - /* before determining the measurement rule outcome. */ + /* On measurement. 'Continous' sensors only. + * The basic function is to trigger communication after measurement if at least 60s have passed since the last one. * + * Transmission may occur every n-th measurement. Optionally dependency on the other rule can be configured, then, when all * + * conditions are met, transmission is triggered. * + * parameter[0] - Send every n-th measurement. This parameter specifies every which measurement transmission will be * + * triggered if all other conditions are met. Range: [1:500]. If parameter[0] equals 1, transmission will * + * occur after every measurement. * + * parameter[1] - Optional. Rule selector (bitmask). Specifies which rule should be taken into account while determining * + * the measurement rule outcome. * + * parameter[2] - Optional. Rule negation (bitmask). Specifies which of the rules selected in parameter[1] should be negated * + * before determining the measurement rule outcome. */ CONDITION_ON_MEASUREMENT = 7; + + /* On sensor error. 'Continous' sensors only. * + * If the sensor returns an error code, an action will be triggered. * + * The rule becomes inactive when a correct measurement appears. */ + CONDITION_ON_SENSOR_ERROR = 8; } /* Logic operators to be used for determining the outcome of rules with logic operator condition. */ enum LogicOperator { - /* Invalid use */ + /* Invalid value. */ LOGIC_OPERATOR_UNSPECIFIED = 0; - /* Logic AND */ + /* Logic AND. */ LOGIC_OPERATOR_AND = 1; - /* Logic OR */ + /* Logic OR. */ LOGIC_OPERATOR_OR = 2; } -/* Action to be triggered. Currently the only possible action is to trigger the transmission. */ -/* Other actions will be available in next SW releases. */ +/* Action to be triggered. */ enum Action { - /* Invalid value */ + /* Invalid value. */ ACTION_UNSPECIFIED = 0; - /* To trigger the transmission */ + /* To trigger the transmission. */ ACTION_TRIGGER_TRANSMISSION = 1; - /* To take no action. Possible for logic operator components */ + /* To take no action. Possible for logic operator components. */ ACTION_NO_ACTION = 2; - /* To trigger the transmission with ACK */ + /* To trigger the transmission with ACK. */ ACTION_TRIGGER_TRANSMISSION_WITH_ACK = 3; - /* To change BLE advertising period mode to fast (with lower user-configured advertising interval). */ - /* Once the rule is deactived avertising period mode returns to previously configured value. */ + /* To change BLE advertising period mode to fast (with lower user-configured advertising interval). * + * Once the rule is deactived avertising period mode returns to previously configured value. */ ACTION_FAST_ADVERTISING_MODE = 4; } /* Type of a rule calendars. */ enum CalendarType { - /* Invalid value */ + /* Invalid value. */ CALENDAR_TYPE_UNSPECIFIED = 0; - /* Type for inactive calendars */ + /* Calendar is inactive. */ CALENDAR_TYPE_DISABLED = 1; - /* Week type. Enables selcted rules on specified days of the week in specified time periods. */ - /* parameter[0] - Week day mask. Bitmask of days when selected rules are enabled */ - /* - Bit 0 - Sunday */ - /* - Bit 1 - Monday */ - /* ... */ - /* - Bit 6 - Saturday */ - /* parameter[1] - 'From time' - point in time from which selected rules will be enabled (in minutes from midnight). */ - /* parameter[2] - 'To time' - point in time from which selected rules will be disabled (in minutes from midnight). */ - /* Note: if 'From time' is bigger than 'To time' there are two periods when rules are enabled - from 00:00 to 'To time' */ - /* and from 'From time' to 23:59. */ - /* parameter[3] - Timezone - desired timezone for date comparison. Encoded as number (N) of 15 minutes offsets */ - /* - example - if N = 4, then offset = 4 * 15min = 1h. I.e. timezone is UTC+1. */ + /* Week calendar. Enables selcted rules on specified days of the week in specified time periods. * + * parameter[0] - Week day mask. Bitmask of days when selected rules are enabled: * + * - Bit 0 - Sunday * + * - Bit 1 - Monday * + * ... * + * - Bit 6 - Saturday * + * parameter[1] - 'From time' - point in time from which selected rules will be enabled (in minutes from midnight). * + * parameter[2] - 'To time' - point in time from which selected rules will be disabled (in minutes from midnight). * + * parameter[3] - Timezone - desired timezone for date comparison. Encoded as number (N) of 15 minutes offsets, * + * for example: if N = 4, then offset = 4 * 15min = 1h. I.e. timezone is UTC+1. * + * Note: If 'From time' is bigger than 'To time' there are two periods when rules are enabled - from 00:00 to 'To time' * + * and from 'From time' to 23:59. */ CALENDAR_TYPE_WEEK = 2; } /* Rules calendars. Used for enabling/disabling rules based on date/time. */ -/* It is possible to configure up to 6 calendars. Each of them can affect any number of rules. */ message ProtoCalendar { - /* Bit mask of selected rules. Mask on bits [0:11] */ - /* - Bit 0 - Rule ID 0 */ - /* - Bit 1 - Rule ID 1 */ - /* ... */ - /* - Bit 11 - Rule ID 11 */ + /* Selected rules. * + * Bitmask - up to 16 rules supported (previously 12 rules [06.00 - 07.01.xx]): * + * - Bit 0 - Rule 1 * + * - Bit 1 - Rule 2 * + * ... * + * - Bit 15 - Rule 16 * + * Status: In use [06.08.00 - LATEST] */ uint32 rule_mask = 1; - /* Calendars's parameters. Described in Type. */ + /* Calendar parameters (as described in CalendarType). * + * Status: In use [06.08.00 - LATEST] */ repeated sint32 parameters = 2; - /* Calendar's type. Described in Type. */ + /* Calendar type (as described in CalendarType). * + * Status: In use [06.08.00 - LATEST] */ CalendarType type = 3; } -/* Rules used to define edge logic on the device. Rules are defined by conditions and actions: */ -/* If Condition is true, trigger Action. It is possible to configure up to 12 rules and assign them to different channels. */ -/* One rule can be assigned to any number of channels. For instance rule "If temperature is over 10 C, trigger the transmission"*/ -/* can be assigned to channels 1 and 2. No matter to how many channels a rule is assigned, it's still counted as one rule. */ +/* Rules are used to define edge logic on the device. Rules are defined by conditions and actions. * + * If 'condition' is true, trigger 'action'. One rule can be assigned to any number of channels. * + * For instance rule "If temperature is over 10 C, trigger the transmission" can be assigned to channels 1 and 2. * + * No matter to how many channels a rule is assigned, it's still counted as one rule. */ message ProtoRule { - /* Channels to which the rule is assigned. One rule can be assigned to multiple channels as long as those are of the same type*/ - /* Bit mask on bits [0:5]. E.g. To assign the rule for channel 1: "000001", to assign rule to channels 2 and 4: "001010" */ + /* Channels to which the rule is assigned. * + * One rule can be assigned to multiple channels as long as those are of the same type. * + * Bitmask: * + * - Bit 0 - Channel 1 * + * - Bit 1 - Channel 2 * + * ... * + * - Bit 5 - Channel 6 * + * Status: In use [06.00 - LATEST] */ uint32 channel_mask = 1; - /* Rule's condition (as described in Condition). */ + /* Rule condition (as described in Condition). * + * Status: In use [06.00 - LATEST] */ Condition condition = 2; - /* Condition's parameters (as described in Condition). For binary sensors there are no parameters */ + /* Condition parameters (as described in Condition). * + * For 'Binary' sensors there are no parameters. * + * Status: In use [06.00 - LATEST] */ repeated sint32 parameters = 3; - /* Action to be triggered. */ + /* Action to be triggered. * + * Status: In use [06.00 - LATEST] */ Action action = 4; } \ No newline at end of file diff --git a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java index eb0a764f5e..f6eaa9976f 100644 --- a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java +++ b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java @@ -16,23 +16,32 @@ package org.thingsboard.server.transport.coap.efento; import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.thingsboard.server.gen.transport.coap.ConfigProtos; +import org.thingsboard.server.gen.transport.coap.ConfigTypesProtos; +import org.thingsboard.server.gen.transport.coap.DeviceInfoProtos; import org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType; import org.thingsboard.server.gen.transport.coap.MeasurementsProtos; import org.thingsboard.server.gen.transport.coap.MeasurementsProtos.ProtoMeasurements; +import org.thingsboard.server.gen.transport.coap.ProtoRuleProtos; import org.thingsboard.server.transport.coap.CoapTransportContext; import org.thingsboard.server.transport.coap.efento.utils.CoapEfentoUtils; import java.nio.ByteBuffer; +import java.text.SimpleDateFormat; import java.time.Instant; import java.util.Arrays; +import java.util.Comparator; +import java.util.Date; import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -85,7 +94,7 @@ class CoapEfentoTransportResourceTest { void checkContinuousSensorWithSomeMeasurements() { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(5) @@ -93,7 +102,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() .setType(MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) .setTimestamp(Math.toIntExact(tsInSec)) @@ -114,7 +123,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 180 * 5) * 1000); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("temperature_1").getAsDouble()).isEqualTo(22.4); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("humidity_2").getAsDouble()).isEqualTo(30); - checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 5, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 5); } @ParameterizedTest @@ -122,7 +131,7 @@ class CoapEfentoTransportResourceTest { void checkContinuousSensor(MeasurementType measurementType, List sampleOffsets, String property, double expectedValue) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -130,7 +139,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() .setType(measurementType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -142,7 +151,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsDouble()).isEqualTo(expectedValue); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkContinuousSensor() { @@ -176,7 +185,7 @@ class CoapEfentoTransportResourceTest { String totalPropertyName, double expectedTotalValue) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -184,7 +193,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(Arrays.asList(MeasurementsProtos.ProtoChannel.newBuilder() .setType(majorType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -200,7 +209,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(totalPropertyName + "_2").getAsDouble()).isEqualTo(expectedTotalValue); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkPulseCounterSensors() { @@ -220,7 +229,7 @@ class CoapEfentoTransportResourceTest { void checkBinarySensor() { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -228,7 +237,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() .setType(MEASUREMENT_TYPE_OK_ALARM) .setTimestamp(Math.toIntExact(tsInSec)) @@ -239,7 +248,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get("ok_alarm_1").getAsString()).isEqualTo("ALARM"); - checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 14, true); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 14); } @ParameterizedTest @@ -247,7 +256,7 @@ class CoapEfentoTransportResourceTest { void checkBinarySensorWhenValueIsVarying(MeasurementType measurementType, String property, String expectedValueWhenOffsetNotOk, String expectedValueWhenOffsetOk) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(1) @@ -255,7 +264,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() .setType(measurementType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -268,7 +277,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetNotOk); assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 9) * 1000); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetOk); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, true); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkBinarySensorWhenValueIsVarying() { @@ -282,7 +291,7 @@ class CoapEfentoTransportResourceTest { @Test void checkExceptionWhenChannelsListIsEmpty() { ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(1) @@ -290,7 +299,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .build(); UUID sessionId = UUID.randomUUID(); @@ -299,29 +308,587 @@ class CoapEfentoTransportResourceTest { .hasMessage("[" + sessionId + "]: Failed to get Efento measurements, reason: channels list is empty!"); } + // ------------------------------------------------------------------------- + // ProtoDeviceInfo parsing tests + // ------------------------------------------------------------------------- + @Test - void checkExceptionWhenValuesMapIsEmpty() { - long tsInSec = Instant.now().getEpochSecond(); - ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) - .setCloudToken("test_token") - .setMeasurementPeriodBase(180) - .setMeasurementPeriodFactor(1) - .setBatteryStatus(true) - .setSignal(0) - .setNextTransmissionAt(1000) - .setTransferReason(0) - .setHash(0) - .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() - .setType(MEASUREMENT_TYPE_TEMPERATURE) - .setTimestamp(Math.toIntExact(tsInSec)) + void getEfentoDeviceInfo_parsesSwVersion() { + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setSwVersion(1546) // ver 06.10 => 0x060A => 1546 + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + + assertThat(result.getValues().getAsJsonObject().get("sw_version").getAsInt()).isEqualTo(1546); + } + + @Test + void getEfentoDeviceInfo_parsesAllMemoryStatistics() { + // proto uint32 maps to Java int; the "undefined" sentinel is 0xFFFFFFFF = -1 in signed int + int undefinedTs = -1; + int knownTs = 1_700_000_000; // arbitrary known Unix timestamp fitting in uint32 + // clearMemoryStatistics() is needed because minimalDeviceInfo() pre-populates 22 zeros; + // addAllMemoryStatistics() appends in proto, so without clear the test values land at indices 22-43 + DeviceInfoProtos.ProtoDeviceInfo.Builder builder = minimalDeviceInfo().clearMemoryStatistics(); + builder.addAllMemoryStatistics(List.of( + 0, // [0] nv_storage_status: 0 = no errors + knownTs, // [1] timestamp_of_the_end_of_collecting_statistics + 1048576, // [2] capacity_of_memory_in_bytes + 512000, // [3] used_space_in_bytes + 1024, // [4] size_of_invalid_packets_in_bytes + 256, // [5] size_of_corrupted_packets_in_bytes + 100, // [6] number_of_valid_packets + 10, // [7] number_of_invalid_packets + 2, // [8] number_of_corrupted_packets + 500, // [9] number_of_all_samples_for_channel_1 + 400, // [10] number_of_all_samples_for_channel_2 + 300, // [11] number_of_all_samples_for_channel_3 + 200, // [12] number_of_all_samples_for_channel_4 + 100, // [13] number_of_all_samples_for_channel_5 + 50, // [14] number_of_all_samples_for_channel_6 + undefinedTs, // [15] timestamp_of_the_first_binary_measurement (undefined) + undefinedTs, // [16] timestamp_of_the_last_binary_measurement (undefined) + undefinedTs, // [17] timestamp_of_the_first_binary_measurement_sent (undefined) + knownTs, // [18] timestamp_of_the_first_continuous_measurement + knownTs, // [19] timestamp_of_the_last_continuous_measurement + knownTs, // [20] timestamp_of_the_last_continuous_measurement_sent + 42 // [21] nvm_write_counter + )); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(builder.build()); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("nv_storage_status").getAsLong()).isEqualTo(0); + assertThat(json.get("timestamp_of_the_end_of_collecting_statistics").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("capacity_of_memory_in_bytes").getAsLong()).isEqualTo(1048576L); + assertThat(json.get("used_space_in_bytes").getAsLong()).isEqualTo(512000L); + assertThat(json.get("size_of_invalid_packets_in_bytes").getAsLong()).isEqualTo(1024L); + assertThat(json.get("size_of_corrupted_packets_in_bytes").getAsLong()).isEqualTo(256L); + assertThat(json.get("number_of_valid_packets").getAsLong()).isEqualTo(100L); + assertThat(json.get("number_of_invalid_packets").getAsLong()).isEqualTo(10L); + assertThat(json.get("number_of_corrupted_packets").getAsLong()).isEqualTo(2L); + assertThat(json.get("number_of_all_samples_for_channel_1").getAsLong()).isEqualTo(500L); + assertThat(json.get("number_of_all_samples_for_channel_2").getAsLong()).isEqualTo(400L); + assertThat(json.get("number_of_all_samples_for_channel_3").getAsLong()).isEqualTo(300L); + assertThat(json.get("number_of_all_samples_for_channel_4").getAsLong()).isEqualTo(200L); + assertThat(json.get("number_of_all_samples_for_channel_5").getAsLong()).isEqualTo(100L); + assertThat(json.get("number_of_all_samples_for_channel_6").getAsLong()).isEqualTo(50L); + assertThat(json.get("timestamp_of_the_first_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement_sent").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_continuous_measurement").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("timestamp_of_the_last_continuous_measurement").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("timestamp_of_the_last_continuous_measurement_sent").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("nvm_write_counter").getAsLong()).isEqualTo(42L); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfo() { + // 34 modem parameters (indices 0-33) as defined in proto_device_info.proto + List params = List.of( + 0, // [0] sc_EARNFCN_offset + 1000, // [1] sc_EARFCN + 42, // [2] sc_PCI + 123456, // [3] sc_Cell_id + -90, // [4] sc_RSRP + -10, // [5] sc_RSRQ + -80, // [6] sc_RSSI + 15, // [7] sc_SINR + 3, // [8] sc_Band + 1234, // [9] sc_TAC + 1, // [10] sc_ECL + -30, // [11] sc_TX_PWR + 2, // [12] op_mode + 999, // [13] nc_EARFCN + 1, // [14] nc_EARNFCN_offset + 100, // [15] nc_PCI + -95, // [16] nc_RSRP + 5, // [17] RLC_UL_BLER + 3, // [18] RLC_DL_BLER + 4, // [19] MAC_UL_BLER + 2, // [20] MAC_DL_BLER + 50000,// [21] MAC_UL_TOTAL_BYTES + 60000,// [22] MAC_DL_TOTAL_BYTES + 200, // [23] MAC_UL_total_HARQ_Tx + 150, // [24] MAC_DL_total_HARQ_Tx + 10, // [25] MAC_UL_HARQ_re_Tx + 8, // [26] MAC_DL_HARQ_re_Tx + 1000, // [27] RLC_UL_tput + 1200, // [28] RLC_DL_tput + 900, // [29] MAC_UL_tput + 1100, // [30] MAC_DL_tput + 5000, // [31] sleep_duration + 300, // [32] rx_time + 100 // [33] tx_time + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC66) + .addAllParameters(params) .build()) .build(); - UUID sessionId = UUID.randomUUID(); - assertThatThrownBy(() -> coapEfentoTransportResource.getEfentoMeasurements(measurements, sessionId)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_BC66"); + assertThat(json.get("sc_EARNFCN_offset").getAsInt()).isEqualTo(0); + assertThat(json.get("sc_EARFCN").getAsInt()).isEqualTo(1000); + assertThat(json.get("sc_PCI").getAsInt()).isEqualTo(42); + assertThat(json.get("sc_Cell_id").getAsInt()).isEqualTo(123456); + assertThat(json.get("sc_RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("sc_RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("sc_RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("sc_SINR").getAsInt()).isEqualTo(15); + assertThat(json.get("sc_Band").getAsInt()).isEqualTo(3); + assertThat(json.get("sc_TAC").getAsInt()).isEqualTo(1234); + assertThat(json.get("sc_ECL").getAsInt()).isEqualTo(1); + assertThat(json.get("sc_TX_PWR").getAsInt()).isEqualTo(-30); + assertThat(json.get("op_mode").getAsInt()).isEqualTo(2); + assertThat(json.get("nc_EARFCN").getAsInt()).isEqualTo(999); + assertThat(json.get("nc_EARNFCN_offset").getAsInt()).isEqualTo(1); + assertThat(json.get("nc_PCI").getAsInt()).isEqualTo(100); + assertThat(json.get("nc_RSRP").getAsInt()).isEqualTo(-95); + assertThat(json.get("RLC_UL_BLER").getAsInt()).isEqualTo(5); + assertThat(json.get("RLC_DL_BLER").getAsInt()).isEqualTo(3); + assertThat(json.get("MAC_UL_BLER").getAsInt()).isEqualTo(4); + assertThat(json.get("MAC_DL_BLER").getAsInt()).isEqualTo(2); + assertThat(json.get("MAC_UL_TOTAL_BYTES").getAsInt()).isEqualTo(50000); + assertThat(json.get("MAC_DL_TOTAL_BYTES").getAsInt()).isEqualTo(60000); + assertThat(json.get("MAC_UL_total_HARQ_Tx").getAsInt()).isEqualTo(200); + assertThat(json.get("MAC_DL_total_HARQ_Tx").getAsInt()).isEqualTo(150); + assertThat(json.get("MAC_UL_HARQ_re_Tx").getAsInt()).isEqualTo(10); + assertThat(json.get("MAC_DL_HARQ_re_Tx").getAsInt()).isEqualTo(8); + assertThat(json.get("RLC_UL_tput").getAsInt()).isEqualTo(1000); + assertThat(json.get("RLC_DL_tput").getAsInt()).isEqualTo(1200); + assertThat(json.get("MAC_UL_tput").getAsInt()).isEqualTo(900); + assertThat(json.get("MAC_DL_tput").getAsInt()).isEqualTo(1100); + assertThat(json.get("sleep_duration").getAsInt()).isEqualTo(5000); + assertThat(json.get("rx_time").getAsInt()).isEqualTo(300); + assertThat(json.get("tx_time").getAsInt()).isEqualTo(100); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfoBC660() { + // 22 modem parameters for MODEM_TYPE_BC660 as defined in proto_device_info.proto + List params = List.of( + 1000, // [0] sc_EARFCN + 5, // [1] sc_EARNFCN_offset + 42, // [2] sc_PCI + 123456, // [3] sc_Cell_id + -90, // [4] sc_RSRP + -10, // [5] sc_RSRQ + -80, // [6] sc_RSSI + 15, // [7] sc_SINR + 3, // [8] sc_Band + 1234, // [9] sc_TAC + 1, // [10] sc_ECL + -30, // [11] sc_TX_PWR + 2, // [12] op_mode + 999, // [13] nc_EARFCN + 100, // [14] nc_PCI + -95, // [15] nc_RSRP + -12, // [16] nc_RSRQ + 5000, // [17] sleep_duration + 300, // [18] rx_time + 100, // [19] tx_time + 1, // [20] PLMN_state + 26201 // [21] select_PLMN + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC660) + .addAllParameters(params) + .setSimCardIdentification("89001012012341234120") + .setFirmwareVersion(DeviceInfoProtos.ModemFirmwareVersion.MODEM_FIRMWARE_VERSION_BC660_V2) + .setModemIdentification("123456789012345") + .addAllModemStatistics(List.of(10, 3600, 7200, 600)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_BC660"); + assertThat(json.get("sim_card_identification").getAsString()).isEqualTo("89001012012341234120"); + assertThat(json.get("firmware_version").getAsString()).isEqualTo("MODEM_FIRMWARE_VERSION_BC660_V2"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("123456789012345"); + assertThat(json.get("modem_transmissions_count").getAsInt()).isEqualTo(10); + assertThat(json.get("modem_time_since_last_devinfo").getAsInt()).isEqualTo(3600); + assertThat(json.get("modem_total_psm_time").getAsInt()).isEqualTo(7200); + assertThat(json.get("modem_total_active_time").getAsInt()).isEqualTo(600); + assertThat(json.get("sc_EARFCN").getAsInt()).isEqualTo(1000); + assertThat(json.get("sc_EARNFCN_offset").getAsInt()).isEqualTo(5); + assertThat(json.get("sc_PCI").getAsInt()).isEqualTo(42); + assertThat(json.get("sc_Cell_id").getAsInt()).isEqualTo(123456); + assertThat(json.get("sc_RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("sc_RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("sc_RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("sc_SINR").getAsInt()).isEqualTo(15); + assertThat(json.get("sc_Band").getAsInt()).isEqualTo(3); + assertThat(json.get("sc_TAC").getAsInt()).isEqualTo(1234); + assertThat(json.get("sc_ECL").getAsInt()).isEqualTo(1); + assertThat(json.get("sc_TX_PWR").getAsInt()).isEqualTo(-30); + assertThat(json.get("op_mode").getAsInt()).isEqualTo(2); + assertThat(json.get("nc_EARFCN").getAsInt()).isEqualTo(999); + assertThat(json.get("nc_PCI").getAsInt()).isEqualTo(100); + assertThat(json.get("nc_RSRP").getAsInt()).isEqualTo(-95); + assertThat(json.get("nc_RSRQ").getAsInt()).isEqualTo(-12); + assertThat(json.get("sleep_duration").getAsInt()).isEqualTo(5000); + assertThat(json.get("rx_time").getAsInt()).isEqualTo(300); + assertThat(json.get("tx_time").getAsInt()).isEqualTo(100); + assertThat(json.get("PLMN_state").getAsInt()).isEqualTo(1); + assertThat(json.get("select_PLMN").getAsInt()).isEqualTo(26201); + // BC66-specific fields must not be present + assertThat(json.has("nc_EARNFCN_offset")).isFalse(); + assertThat(json.has("RLC_UL_BLER")).isFalse(); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfoSharedModem() { + // 4 modem parameters for MODEM_TYPE_SHARED_MODEM as defined in proto_device_info.proto + List params = List.of( + -90, // [0] RSRP + -10, // [1] RSRQ + -80, // [2] RSSI + 15 // [3] SINR + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_SHARED_MODEM) + .addAllParameters(params) + .setModemIdentification("SN-ABCDEF") + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_SHARED_MODEM"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("SN-ABCDEF"); + assertThat(json.get("RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("SINR").getAsInt()).isEqualTo(15); + // BC66/BC660-specific fields must not be present + assertThat(json.has("sc_EARFCN")).isFalse(); + assertThat(json.has("sc_EARNFCN_offset")).isFalse(); + } + + @Test + void getEfentoDeviceInfo_parsesNewModemFields() { + // New ProtoModem fields: sim_card_identification, firmware_version, modem_identification, modem_statistics + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC66) + .addAllParameters(java.util.Collections.nCopies(34, 0)) + .setSimCardIdentification("89012345678901234567") + .setFirmwareVersion(DeviceInfoProtos.ModemFirmwareVersion.MODEM_FIRMWARE_VERSION_READING_ERROR) + .setModemIdentification("352519100417272") + .addAllModemStatistics(List.of(5, 1800, 3600, 120)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("sim_card_identification").getAsString()).isEqualTo("89012345678901234567"); + assertThat(json.get("firmware_version").getAsString()).isEqualTo("MODEM_FIRMWARE_VERSION_READING_ERROR"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("352519100417272"); + assertThat(json.get("modem_transmissions_count").getAsInt()).isEqualTo(5); + assertThat(json.get("modem_time_since_last_devinfo").getAsInt()).isEqualTo(1800); + assertThat(json.get("modem_total_psm_time").getAsInt()).isEqualTo(3600); + assertThat(json.get("modem_total_active_time").getAsInt()).isEqualTo(120); + } + + @Test + void getEfentoDeviceInfo_parsesRuntimeInfo() { + long batteryResetTs = 1_700_000_000L; + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setRuntimeInfo(DeviceInfoProtos.ProtoRuntime.newBuilder() + .setUpTime(3600) + .addAllMessageCounters(List.of(10, 5, 9)) + .setMcuTemperature(25) + .setBatteryVoltage(3200) + .setMinBatteryMcuTemperature(20) + .setBatteryResetTimestamp((int) batteryResetTs) + .setMaxMcuTemperature(40) + .setMinMcuTemperature(10) + .addAllRuntimeErrors(List.of(0, 1)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("up_time").getAsLong()).isEqualTo(3600); + assertThat(json.get("mcu_temp").getAsInt()).isEqualTo(25); + assertThat(json.get("min_battery_voltage").getAsLong()).isEqualTo(3200); + assertThat(json.get("min_battery_mcu_temp").getAsInt()).isEqualTo(20); + assertThat(json.get("battery_reset_timestamp").getAsString()).isEqualTo(formatDate(batteryResetTs)); + assertThat(json.get("max_mcu_temp").getAsInt()).isEqualTo(40); + assertThat(json.get("min_mcu_temp").getAsInt()).isEqualTo(10); + assertThat(json.get("counter_of_confirmable_messages_attempts").getAsInt()).isEqualTo(10); + assertThat(json.get("counter_of_non_confirmable_messages_attempts").getAsInt()).isEqualTo(5); + assertThat(json.get("counter_of_succeeded_messages").getAsInt()).isEqualTo(9); + assertThat(json.get("runtime_errors").getAsInt()).isEqualTo(2); // count of errors, not values + } + + @Test + void getEfentoDeviceInfo_undefinedTimestampRenderedAsUndefinedString() { + // -1 as signed int == 0xFFFFFFFF == uint32 max (4294967295), the "Undefined" sentinel + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo().clearMemoryStatistics() + .addAllMemoryStatistics(List.of( + 0, -1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + -1, -1, -1, + -1, -1, -1, + 0)) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("timestamp_of_the_end_of_collecting_statistics").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement_sent").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_continuous_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_continuous_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_continuous_measurement_sent").getAsString()).isEqualTo("Undefined"); + } + + @Test + void getEfentoDeviceInfo_tsIsCurrentTimeMillis() { + long before = System.currentTimeMillis(); + CoapEfentoTransportResource.EfentoTelemetry result = + coapEfentoTransportResource.getEfentoDeviceInfo(minimalDeviceInfo().build()); + long after = System.currentTimeMillis(); + + assertThat(result.getTs()).isBetween(before, after); + } + + // ------------------------------------------------------------------------- + // ProtoConfig / getEfentoConfiguration parsing tests + // ------------------------------------------------------------------------- + + @Test + void getEfentoConfiguration_parsesServerCommunicationFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDataServerIp("18.184.24.239") + .setDataServerPort(5683) + .setUpdateServerIp("efento.update.io") + .setUpdateServerPortCoap(5684) + .setUpdateServerPortUdp(5685) + .setTransmissionInterval(300) + .setAckInterval(600) + .setSupervisionPeriod(3600) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("dataServerIp").getAsString()).isEqualTo("18.184.24.239"); + assertThat(json.get("dataServerPort").getAsLong()).isEqualTo(5683); + assertThat(json.get("updateServerIp").getAsString()).isEqualTo("efento.update.io"); + assertThat(json.get("updateServerPortCoap").getAsLong()).isEqualTo(5684); + assertThat(json.get("updateServerPortUdp").getAsLong()).isEqualTo(5685); + assertThat(json.get("transmissionInterval").getAsLong()).isEqualTo(300); + assertThat(json.get("ackInterval").getAsLong()).isEqualTo(600); + assertThat(json.get("supervisionPeriod").getAsLong()).isEqualTo(3600); + } + + @Test + void getEfentoConfiguration_parsesMeasurementPeriod() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setMeasurementPeriodBase(60) + .setMeasurementPeriodFactor(5) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("measurementPeriodBase").getAsLong()).isEqualTo(60); + assertThat(json.get("measurementPeriodFactor").getAsLong()).isEqualTo(5); + } + + @Test + void getEfentoConfiguration_parsesBooleanRequestFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDeviceInfoRequest(true) + .setUpdateSoftwareRequest(true) + .setConfigurationRequest(true) + .setAcceptWithoutTestingRequest(true) + .setResetMemoryRequest(true) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("deviceInfoRequest").getAsBoolean()).isTrue(); + assertThat(json.get("updateSoftwareRequest").getAsBoolean()).isTrue(); + assertThat(json.get("configurationRequest").getAsBoolean()).isTrue(); + assertThat(json.get("acceptWithoutTestingRequest").getAsBoolean()).isTrue(); + assertThat(json.get("resetMemoryRequest").getAsBoolean()).isTrue(); + } + + @Test + void getEfentoConfiguration_parsesModemAndNetworkFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setApn("internet.example.com") + .setApnUsername("user") + .setApnPassword("pass") + .setPlmnSelection(26001) + .setModemBandsMask(2084) // bands 3, 8, 20 + .setNetworkTroubleshooting(2) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("apn").getAsString()).isEqualTo("internet.example.com"); + assertThat(json.get("apnUsername").getAsString()).isEqualTo("user"); + assertThat(json.get("apnPassword").getAsString()).isEqualTo("pass"); + assertThat(json.get("plmnSelection").getAsLong()).isEqualTo(26001); + assertThat(json.get("modemBandsMask").getAsLong()).isEqualTo(2084); + assertThat(json.get("networkTroubleshooting").getAsLong()).isEqualTo(2); + } + + @Test + void getEfentoConfiguration_parsesCloudTokenFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setCloudToken("my-device-token") + .setCloudTokenConfig(1) + .setCloudTokenCoapOption(65000) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("cloudToken").getAsString()).isEqualTo("my-device-token"); + assertThat(json.get("cloudTokenConfig").getAsLong()).isEqualTo(1); + assertThat(json.get("cloudTokenCoapOption").getAsLong()).isEqualTo(65000); + } + + @Test + void getEfentoConfiguration_parsesEndpointFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDataEndpoint("/m") + .setConfigurationEndpoint("/c") + .setDeviceInfoEndpoint("/i") + .setTimeEndpoint("/t") + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("dataEndpoint").getAsString()).isEqualTo("/m"); + assertThat(json.get("configurationEndpoint").getAsString()).isEqualTo("/c"); + assertThat(json.get("deviceInfoEndpoint").getAsString()).isEqualTo("/i"); + assertThat(json.get("timeEndpoint").getAsString()).isEqualTo("/t"); + } + + @Test + void getEfentoConfiguration_parsesBleAdvertisingPeriod() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setBleAdvertisingPeriod(ConfigTypesProtos.ProtoBleAdvertisingPeriod.newBuilder() + .setMode(ConfigTypesProtos.BleAdvertisingPeriodMode.BLE_ADVERTISING_PERIOD_MODE_NORMAL) + .setNormal(1600) + .setFast(320) + .build()) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var ble = json.get("bleAdvertisingPeriod").getAsJsonObject(); + + assertThat(ble.get("mode").getAsString()).isEqualTo("BLE_ADVERTISING_PERIOD_MODE_NORMAL"); + assertThat(ble.get("normal").getAsLong()).isEqualTo(1600); + assertThat(ble.get("fast").getAsLong()).isEqualTo(320); + } + + @Test + void getEfentoConfiguration_parsesRepeatedChannelTypes() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_HUMIDITY) + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var channelTypes = json.get("channelTypes").getAsJsonArray(); + + assertThat(channelTypes).hasSize(3); + assertThat(channelTypes.get(0).getAsString()).isEqualTo("MEASUREMENT_TYPE_TEMPERATURE"); + assertThat(channelTypes.get(1).getAsString()).isEqualTo("MEASUREMENT_TYPE_HUMIDITY"); + assertThat(channelTypes.get(2).getAsString()).isEqualTo("MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE"); + } + + @Test + void getEfentoConfiguration_parsesEdgeLogicRules() throws InvalidProtocolBufferException { + // ProtoRule uses channel_mask (bit mask), condition, parameters, action + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .addRules(ProtoRuleProtos.ProtoRule.newBuilder() + .setChannelMask(1) // channel 1 → bit mask 0b000001 + .setCondition(ProtoRuleProtos.Condition.CONDITION_HIGH_THRESHOLD) + .addParameters(500) // threshold value + .setAction(ProtoRuleProtos.Action.ACTION_TRIGGER_TRANSMISSION) + .build()) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var rules = json.get("rules").getAsJsonArray(); + + assertThat(rules).hasSize(1); + var rule = rules.get(0).getAsJsonObject(); + assertThat(rule.get("channelMask").getAsInt()).isEqualTo(1); + assertThat(rule.get("condition").getAsString()).isEqualTo("CONDITION_HIGH_THRESHOLD"); + assertThat(rule.get("parameters").getAsJsonArray().get(0).getAsInt()).isEqualTo(500); + assertThat(rule.get("action").getAsString()).isEqualTo("ACTION_TRIGGER_TRANSMISSION"); + } + + @Test + void getEfentoConfiguration_emptyConfigProducesJsonWithDefaultValues() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder().build().toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + // JsonFormat with includingDefaultValueFields prints all fields — verify a representative subset + assertThat(json.get("measurementPeriodBase").getAsLong()).isEqualTo(0); + assertThat(json.get("transmissionInterval").getAsLong()).isEqualTo(0); + assertThat(json.get("dataServerIp").getAsString()).isEmpty(); + assertThat(json.get("deviceInfoRequest").getAsBoolean()).isFalse(); + assertThat(json.get("cloudToken").getAsString()).isEmpty(); + } + + /** + * Builds a ProtoDeviceInfo with the minimum set of repeated fields required by getEfentoDeviceInfo: + * 22 memory_statistics, 34 modem parameters and 3 message_counters — all set to 0. + */ + private static DeviceInfoProtos.ProtoDeviceInfo.Builder minimalDeviceInfo() { + List zeroMemStats = java.util.Collections.nCopies(22, 0); + List zeroModemParams = java.util.Collections.nCopies(34, 0); + return DeviceInfoProtos.ProtoDeviceInfo.newBuilder() + .setSerialNumber(integerToByteString(1234)) + .setCloudToken("test_token") + .setSwVersion(0) + .addAllMemoryStatistics(zeroMemStats) + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_UNSPECIFIED) + .addAllParameters(zeroModemParams) + .build()) + .setRuntimeInfo(DeviceInfoProtos.ProtoRuntime.newBuilder() + .setUpTime(0) + .addAllMessageCounters(List.of(0, 0, 0)) + .build()); + } + + private static String formatDate(long seconds) { + SimpleDateFormat sdf = new SimpleDateFormat("dd MMM yyyy HH:mm:ss Z"); + return sdf.format(new Date(TimeUnit.SECONDS.toMillis(seconds))); } public static ByteString integerToByteString(Integer intValue) { @@ -340,20 +907,17 @@ class CoapEfentoTransportResourceTest { private void checkDefaultMeasurements(ProtoMeasurements incomingMeasurements, List actualEfentoMeasurements, - long expectedMeasurementInterval, - boolean isBinarySensor) { - for (int i = 0; i < actualEfentoMeasurements.size(); i++) { - CoapEfentoTransportResource.EfentoTelemetry actualEfentoMeasurement = actualEfentoMeasurements.get(i); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNum().toByteArray())); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); - MeasurementsProtos.ProtoChannel protoChannel = incomingMeasurements.getChannelsList().get(0); - long measuredAt = isBinarySensor ? - TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()) + Math.abs(TimeUnit.SECONDS.toMillis(protoChannel.getSampleOffsetsList().get(i))) - 1000 : - TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp() + i * expectedMeasurementInterval); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measured_at").getAsString()).isEqualTo(convertTimestampToUtcString(measuredAt)); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measurement_interval").getAsDouble()).isEqualTo(expectedMeasurementInterval); + long expectedMeasurementInterval) { + CoapEfentoTransportResource.EfentoTelemetry efentoTelemetry = actualEfentoMeasurements.stream() + .sorted(Comparator.comparing(CoapEfentoTransportResource.EfentoTelemetry::getTs)) + .toList().get(0); + + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNumber().toByteArray())); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); + for (int i = 1; i < incomingMeasurements.getChannelsCount() + 1; i++) { + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("measurement_interval_" + i).getAsDouble()).isEqualTo(expectedMeasurementInterval); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index e8ade498db..b5384eada7 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -508,7 +508,9 @@ public class DefaultTransportService extends TransportActivityManager implements @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback callback) { if (checkLimits(sessionInfo, msg, callback)) { - recordActivityInternal(sessionInfo); + if (msg.getEvent() != TransportProtos.SessionEvent.CLOSED) { + recordActivityInternal(sessionInfo); + } sendToDeviceActor(sessionInfo, TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setSessionEvent(msg).build(), callback); } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java index 3b3ce6e600..a656b22241 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java @@ -26,12 +26,14 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.activity.ActivityReportCallback; import org.thingsboard.server.common.transport.activity.ActivityState; import org.thingsboard.server.common.transport.activity.strategy.ActivityStrategy; import org.thingsboard.server.common.transport.activity.strategy.ActivityStrategyType; +import org.thingsboard.server.common.transport.limits.TransportRateLimitService; import org.thingsboard.server.gen.transport.TransportProtos; import java.util.UUID; @@ -41,7 +43,12 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -514,4 +521,55 @@ public class TransportActivityManagerTest { verify(transportServiceMock, never()).process(sessionInfo, SESSION_EVENT_MSG_CLOSED, null); } + @Test + void givenSessionClosedEvent_whenProcessingSessionEvent_thenShouldNotRecordActivity() { + // GIVEN - simulates the session-expiry path: session already removed from sessions map, + // then process(SESSION_CLOSED) is called. Activity must NOT be recorded to avoid active + // status update from false to true + var rateLimitServiceMock = mock(TransportRateLimitService.class); + ReflectionTestUtils.setField(transportServiceMock, "rateLimitService", rateLimitServiceMock); + when(rateLimitServiceMock.checkLimits(any(), nullable(DeviceId.class), any(), anyInt(), anyBoolean())).thenReturn(null); + + TransportProtos.SessionInfoProto sessionInfo = TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(SESSION_ID.getMostSignificantBits()) + .setSessionIdLSB(SESSION_ID.getLeastSignificantBits()) + .build(); + doCallRealMethod().when(transportServiceMock).process(sessionInfo, SESSION_EVENT_MSG_CLOSED, null); + + // WHEN + transportServiceMock.process(sessionInfo, SESSION_EVENT_MSG_CLOSED, null); + + // THEN + verify(transportServiceMock, never()).onActivity(any(), any(), anyLong()); + verify(transportServiceMock).sendToDeviceActor(eq(sessionInfo), any(), isNull()); + } + + @Test + void givenSessionOpenEvent_whenProcessingSessionEvent_thenShouldRecordActivity() { + // GIVEN + var rateLimitServiceMock = mock(TransportRateLimitService.class); + ReflectionTestUtils.setField(transportServiceMock, "rateLimitService", rateLimitServiceMock); + when(rateLimitServiceMock.checkLimits(any(), nullable(DeviceId.class), any(), anyInt(), anyBoolean())).thenReturn(null); + + TransportProtos.SessionInfoProto sessionInfo = TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(SESSION_ID.getMostSignificantBits()) + .setSessionIdLSB(SESSION_ID.getLeastSignificantBits()) + .build(); + var sessionOpenMsg = TransportProtos.SessionEventMsg.newBuilder() + .setSessionType(TransportProtos.SessionType.ASYNC) + .setEvent(TransportProtos.SessionEvent.OPEN) + .build(); + when(transportServiceMock.toSessionId(sessionInfo)).thenReturn(SESSION_ID); + long expectedActivityTime = 100L; + when(transportServiceMock.getCurrentTimeMillis()).thenReturn(expectedActivityTime); + doCallRealMethod().when(transportServiceMock).process(sessionInfo, sessionOpenMsg, null); + + // WHEN + transportServiceMock.process(sessionInfo, sessionOpenMsg, null); + + // THEN + verify(transportServiceMock).onActivity(SESSION_ID, sessionInfo, expectedActivityTime); + verify(transportServiceMock).sendToDeviceActor(eq(sessionInfo), any(), isNull()); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java index 505a319baa..1dec2bbe04 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java @@ -48,13 +48,13 @@ public class BaseAlarmCommentService extends AbstractEntityService implements Al @Override public AlarmComment createOrUpdateAlarmComment(TenantId tenantId, AlarmComment alarmComment) { - alarmCommentDataValidator.validate(alarmComment, c -> tenantId); + AlarmComment oldAlarmComment = alarmCommentDataValidator.validate(alarmComment, c -> tenantId); boolean isCreated = alarmComment.getId() == null; AlarmComment result; if (isCreated) { result = createAlarmComment(tenantId, alarmComment); } else { - result = updateAlarmComment(tenantId, alarmComment); + result = updateAlarmComment(tenantId, alarmComment, oldAlarmComment); } if (result != null) { eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(result) @@ -101,18 +101,17 @@ public class BaseAlarmCommentService extends AbstractEntityService implements Al return alarmCommentDao.save(tenantId, alarmComment); } - private AlarmComment updateAlarmComment(TenantId tenantId, AlarmComment newAlarmComment) { + private AlarmComment updateAlarmComment(TenantId tenantId, AlarmComment newAlarmComment, AlarmComment oldAlarmComment) { log.debug("Update Alarm comment : {}", newAlarmComment); - AlarmComment existing = alarmCommentDao.findAlarmCommentById(tenantId, newAlarmComment.getId().getId()); - if (existing != null) { + if (oldAlarmComment != null) { if (newAlarmComment.getComment() != null) { JsonNode comment = newAlarmComment.getComment(); ((ObjectNode) comment).put("edited", "true"); ((ObjectNode) comment).put("editedOn", System.currentTimeMillis()); - existing.setComment(comment); + oldAlarmComment.setComment(comment); } - return alarmCommentDao.save(tenantId, existing); + return alarmCommentDao.save(tenantId, oldAlarmComment); } return null; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java index 17e90d84bd..f7ab9d8662 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java @@ -15,15 +15,9 @@ */ package org.thingsboard.server.dao.rule; -import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.rule.RuleChainDetails; +import org.thingsboard.server.dao.Dao; -import java.util.UUID; - -public interface RuleChainDetailsDao { - - RuleChainDetails findById(TenantId tenantId, UUID id); - - RuleChainDetails save(TenantId tenantId, RuleChainDetails ruleChainDetails); +public interface RuleChainDetailsDao extends Dao { } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java index ea2060a6a4..b70251027d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java @@ -18,14 +18,18 @@ package org.thingsboard.server.dao.service.validator; import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.alarm.AlarmComment; +import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.exception.DataValidationException; +import org.thingsboard.server.dao.alarm.AlarmCommentDao; import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.exception.DataValidationException; @Component @AllArgsConstructor public class AlarmCommentDataValidator extends DataValidator { + private final AlarmCommentDao alarmCommentDao; + @Override protected void validateDataImpl(TenantId tenantId, AlarmComment alarmComment) { if (alarmComment.getComment() == null) { @@ -35,4 +39,20 @@ public class AlarmCommentDataValidator extends DataValidator { throw new DataValidationException("Alarm id should be specified!"); } } + + @Override + protected AlarmComment validateUpdate(TenantId tenantId, AlarmComment alarmComment) { + AlarmComment oldAlarmComment = null; + if (alarmComment.getId() != null) { + oldAlarmComment = alarmCommentDao.findAlarmCommentById(tenantId, alarmComment.getId().getId()); + if (oldAlarmComment == null) { + throw new DataValidationException("Can't update non existing alarm comment!"); + } + if (oldAlarmComment.getType() == AlarmCommentType.SYSTEM) { + throw new DataValidationException("System alarm comment can't be updated!"); + } + } + return oldAlarmComment; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java index d43ee02add..3381bae386 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java @@ -23,12 +23,14 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.rule.RuleChainDetails; +import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.sql.RuleChainDetailsEntity; import org.thingsboard.server.dao.rule.RuleChainDetailsDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; import org.thingsboard.server.exception.DataValidationException; +import java.util.Optional; import java.util.UUID; @Slf4j @@ -40,9 +42,19 @@ public class JpaRuleChainDetailsDao extends JpaAbstractDao entity = getRepository().findById(key); + // Detaching to avoid stale version conflict with RuleChainEntity which maps to the same table. + // Without detach, a loaded RuleChainDetailsEntity stays in the persistence context and becomes stale + // when the same row is updated via RuleChainEntity (e.g. during rule chain import with circular references). + entity.ifPresent(e -> getEntityManager().detach(e)); + return DaoUtil.getData(entity); + } + + @Override + protected RuleChainDetailsEntity doSave(RuleChainDetailsEntity entity, boolean isNew, boolean flush) { try { - return super.save(tenantId, ruleChainDetails); + return super.doSave(entity, isNew, flush); } catch (Exception e) { String rootMsg = ExceptionUtils.getRootCauseMessage(e); if (StringUtils.contains(rootMsg, "value too long")) { diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java index c951c58060..46254fbe2b 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.service; import com.datastax.oss.driver.api.core.uuid.Uuids; import org.junit.Assert; import org.junit.Test; +import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; @@ -59,11 +60,14 @@ import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.exception.DataValidationException; import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; +import static org.assertj.core.api.Assertions.assertThat; + @DaoSqlTest public class AlarmServiceTest extends AbstractServiceTest { @@ -990,4 +994,25 @@ public class AlarmServiceTest extends AbstractServiceTest { Assert.assertEquals(1, alarmsCount); } + @Test + public void testShouldFailToCreateAlarmWithBadType() { + AssetId originatorId = new AssetId(Uuids.timeBased()); + + long ts = System.currentTimeMillis(); + AlarmCreateOrUpdateActiveRequest request = AlarmCreateOrUpdateActiveRequest.builder() + .tenantId(tenantId) + .originator(originatorId) + .type("") + .severity(AlarmSeverity.CRITICAL) + .startTs(ts).build(); + + Assertions.assertThrows(DataValidationException.class, () -> { + alarmService.createAlarm(request); + }); + + request.setType(TEST_ALARM); + AlarmApiCallResult result = alarmService.createAlarm(request); + assertThat(result.getAlarm().getId()).isNotNull(); + } + } diff --git a/msa/js-executor/docker/Dockerfile b/msa/js-executor/docker/Dockerfile index 1ef157fdd9..736bd56529 100644 --- a/msa/js-executor/docker/Dockerfile +++ b/msa/js-executor/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/node:22.18.0-bookworm-slim +FROM thingsboard/node:22.22.2-bookworm-slim ENV NODE_ENV production ENV DOCKER_MODE true diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index a60111f7f8..66a629f550 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -70,7 +70,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 diff --git a/msa/web-ui/docker/Dockerfile b/msa/web-ui/docker/Dockerfile index 063374a478..b87885b45d 100644 --- a/msa/web-ui/docker/Dockerfile +++ b/msa/web-ui/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/node:22.18.0-bookworm-slim +FROM thingsboard/node:22.22.2-bookworm-slim ENV NODE_ENV production ENV DOCKER_MODE true diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 753cdef6c8..4ad7aef8c8 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -79,7 +79,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 diff --git a/pom.xml b/pom.xml index 4286678475..84d3fdad47 100755 --- a/pom.xml +++ b/pom.xml @@ -63,9 +63,7 @@ /var/log/${pkg.name} /usr/share/${pkg.name} 4.4.0-SNAPSHOT - 3.5.12 - 2.21.1 - 4.1.132.Final + 3.5.13 2.4.0-b180830.0359 0.12.5 0.10 @@ -73,6 +71,7 @@ 4.2.25 5.0.4 33.1.0-jre + 10.1.54 3.18.0 2.16.1 1.3.1 @@ -996,24 +995,25 @@ - + - com.fasterxml.jackson - jackson-bom - ${jackson-bom.version} - pom - import + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} - - - io.netty - netty-bom - ${netty.version} - pom - import + org.apache.tomcat.embed + tomcat-embed-el + ${tomcat.version} + + + org.apache.tomcat.embed + tomcat-embed-websocket + ${tomcat.version} - + org.springframework.boot spring-boot-dependencies diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index d3014ab906..f827dd6e0b 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -441,4 +441,10 @@ public interface TbContext { MqttClientSettings getMqttClientSettings(); + // Server-level safety caps for the HTTP client used by the REST API Call rule node (read from thingsboard.yml) + + default TbHttpClientSettings getTbHttpClientSettings() { + return TbHttpClientSettings.DEFAULT; + } + } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java new file mode 100644 index 0000000000..658f82c0d7 --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java @@ -0,0 +1,49 @@ +/** + * 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.rule.engine.api; + +/** + * Server-level safety caps for the HTTP client used by the REST API Call rule node. + * Values are read from {@code thingsboard.yml} (or the corresponding environment variables) + * and applied as hard ceilings on top of the per-node tenant configuration. + * A value of {@code 0} means no system-level restriction. + */ +public interface TbHttpClientSettings { + + /** System ceiling for {@code maxParallelRequestsCount}. 0 = no system limit. */ + int getMaxParallelRequests(); + + /** System ceiling for the pending-request queue depth. 0 = no system limit. */ + int getMaxPendingRequests(); + + /** + * Maximum number of TCP connections in the reactor-netty pool per node instance. + * 0 = use reactor-netty's default: {@code max(availableProcessors, 8) * 2}. + */ + int getPoolMaxConnections(); + + TbHttpClientSettings DEFAULT = new TbHttpClientSettings() { + @Override + public int getMaxParallelRequests() { return 0; } + + @Override + public int getMaxPendingRequests() { return 0; } + + @Override + public int getPoolMaxConnections() { return 0; } + }; + +} diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java index 4a0f1883c0..8fac42e029 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java @@ -15,6 +15,7 @@ */ package org.thingsboard.rule.engine.api.util; +import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -57,8 +58,12 @@ public final class TbNodeUtils { } public static String processPattern(String pattern, TbMsg tbMsg) { + return processPattern(pattern, tbMsg, false); + } + + public static String processPattern(String pattern, TbMsg tbMsg, boolean escapeJsonValues) { try { - String result = processPattern(pattern, tbMsg.getMetaData()); + String result = processPattern(pattern, tbMsg.getMetaData(), escapeJsonValues); JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData()); result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json)); @@ -79,7 +84,11 @@ public final class TbNodeUtils { } if (jsonNode != null && jsonNode.isValueNode()) { - result = result.replace(formatDataVarTemplate(group), jsonNode.asText()); + String value = jsonNode.asText(); + if (escapeJsonValues) { + value = escapeJsonValue(value); + } + result = result.replace(formatDataVarTemplate(group), value); } } } @@ -89,22 +98,31 @@ public final class TbNodeUtils { } } - private static String processPattern(String pattern, TbMsgMetaData metaData) { + private static String processPattern(String pattern, TbMsgMetaData metaData, boolean escapeJsonValues) { String replacement = metaData.isEmpty() ? "{}" : JacksonUtil.toString(metaData.getData()); pattern = pattern.replace(ALL_METADATA_TEMPLATE, replacement); - return processTemplate(pattern, metaData.values()); + return processTemplate(pattern, metaData.values(), escapeJsonValues); + } + + private static String processPattern(String pattern, TbMsgMetaData metaData) { + return processPattern(pattern, metaData, false); } public static String processTemplate(String template, Map data) { + return processTemplate(template, data, false); + } + + private static String processTemplate(String template, Map data, boolean escapeJsonValues) { String result = template; for (Map.Entry kv : data.entrySet()) { - result = processVar(result, kv.getKey(), kv.getValue()); + String value = escapeJsonValues ? escapeJsonValue(kv.getValue()) : kv.getValue(); + result = result.replace(formatMetadataVarTemplate(kv.getKey()), value); } return result; } - private static String processVar(String pattern, String key, String val) { - return pattern.replace(formatMetadataVarTemplate(key), val); + private static String escapeJsonValue(String value) { + return new String(JsonStringEncoder.getInstance().quoteAsString(value)); } static String formatDataVarTemplate(String key) { diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java index 18003d6b70..882e2080d7 100644 --- a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java @@ -305,6 +305,50 @@ public class TbNodeUtilsTest { assertThat(actual, is(expected)); } + @Test + public void testProcessPatternWithJsonEscaping() { + String pattern = "{\"name\":\"${user}\",\"desc\":\"$[description]\"}"; + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("user", "John \"Doe\""); + + ObjectNode node = JacksonUtil.newObjectNode(); + node.put("description", "line1\nline2"); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(TenantId.SYS_TENANT_ID) + .copyMetaData(md) + .data(JacksonUtil.toString(node)) + .build(); + + String result = TbNodeUtils.processPattern(pattern, msg, true); + Assertions.assertEquals("{\"name\":\"John \\\"Doe\\\"\",\"desc\":\"line1\\nline2\"}", result); + + // Verify the result is valid JSON + Assertions.assertDoesNotThrow(() -> JacksonUtil.toJsonNode(result)); + } + + @Test + public void testProcessPatternWithoutJsonEscaping() { + String pattern = "Hello ${user}, desc: $[description]"; + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("user", "John \"Doe\""); + + ObjectNode node = JacksonUtil.newObjectNode(); + node.put("description", "line1\nline2"); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(TenantId.SYS_TENANT_ID) + .copyMetaData(md) + .data(JacksonUtil.toString(node)) + .build(); + + // Without escaping, raw values are substituted as-is + String result = TbNodeUtils.processPattern(pattern, msg, false); + Assertions.assertEquals("Hello John \"Doe\", desc: line1\nline2", result); + } + @Test public void testMixedAllDataMetadataAndNormalTemplates() { // GIVEN diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java index 8db6a3ccaf..8e017e21f3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java @@ -34,6 +34,7 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti import org.springframework.web.util.UriComponentsBuilder; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; @@ -44,6 +45,7 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.util.KeyValueEntry; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import reactor.core.scheduler.Schedulers; import reactor.netty.http.client.HttpClient; import reactor.netty.resources.ConnectionProvider; import reactor.netty.transport.ProxyProvider; @@ -55,9 +57,13 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Properties; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -87,17 +93,48 @@ public class TbHttpClient { public static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "tb.http.maxInMemoryBufferSizeInKb"; + private static final long ANOMALY_REPORT_INTERVAL_MS = 60_000; + private final TbRestApiCallNodeConfiguration config; + private final String tenantId; + private final String nodeId; + private final TbHttpClientSettings settings; private EventLoopGroup eventLoopGroup; private WebClient webClient; private Semaphore semaphore; + private BlockingQueue pendingQueue; + + private final AtomicLong dispatchedCount = new AtomicLong(); + private final AtomicLong successCount = new AtomicLong(); + private final AtomicLong failureCount = new AtomicLong(); + private final AtomicLong droppedFullCount = new AtomicLong(); + private final AtomicLong droppedStaleCount = new AtomicLong(); + private volatile long lastAnomalyReportAt = 0; + + private record PendingTask( + TbContext ctx, + TbMsg msg, + Consumer onSuccess, + BiConsumer onFailure, + long enqueuedNanos) {} TbHttpClient(TbRestApiCallNodeConfiguration config, EventLoopGroup eventLoopGroupShared) throws TbNodeException { + this(config, eventLoopGroupShared, "n/a", "n/a", TbHttpClientSettings.DEFAULT); + } + + TbHttpClient(TbRestApiCallNodeConfiguration config, EventLoopGroup eventLoopGroupShared, + String tenantId, String nodeId, TbHttpClientSettings settings) throws TbNodeException { try { this.config = config; - if (config.getMaxParallelRequestsCount() > 0) { - semaphore = new Semaphore(config.getMaxParallelRequestsCount()); + this.tenantId = tenantId; + this.nodeId = nodeId; + this.settings = settings; + int effectiveParallel = effectiveMax(config.getMaxParallelRequestsCount(), settings.getMaxParallelRequests()); + if (effectiveParallel > 0) { + semaphore = new Semaphore(effectiveParallel); + int effectivePending = effectiveMax(0, settings.getMaxPendingRequests()); + pendingQueue = effectivePending > 0 ? new LinkedBlockingQueue<>(effectivePending) : new LinkedBlockingQueue<>(); } ConnectionProvider connectionProvider = ConnectionProvider @@ -154,16 +191,20 @@ public class TbHttpClient { } } - private int getPoolMaxConnections() { - String poolMaxConnectionsEnv = System.getenv("TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS"); + /** + * Returns the effective limit: {@code min(userMax, systemMax)} when both are positive, + * {@code systemMax} when only the system ceiling is set, or {@code userMax} otherwise. + * A value of {@code 0} means unlimited. + */ + private static int effectiveMax(int userMax, int systemMax) { + if (systemMax <= 0) return userMax; + if (userMax <= 0) return systemMax; + return Math.min(userMax, systemMax); + } - int poolMaxConnections; - if (poolMaxConnectionsEnv != null) { - poolMaxConnections = Integer.parseInt(poolMaxConnectionsEnv); - } else { - poolMaxConnections = ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS; - } - return poolMaxConnections; + private int getPoolMaxConnections() { + int configured = settings.getPoolMaxConnections(); + return configured > 0 ? configured : ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS; } private void validateMaxInMemoryBufferSize(TbRestApiCallNodeConfiguration config) throws TbNodeException { @@ -182,10 +223,7 @@ public class TbHttpClient { } EventLoopGroup getSharedOrCreateEventLoopGroup(EventLoopGroup eventLoopGroupShared) { - if (eventLoopGroupShared != null) { - return eventLoopGroupShared; - } - return this.eventLoopGroup = new NioEventLoopGroup(); + return Objects.requireNonNullElseGet(eventLoopGroupShared, () -> this.eventLoopGroup = new NioEventLoopGroup()); } private void checkSystemProxyProperties() throws TbNodeException { @@ -206,25 +244,107 @@ public class TbHttpClient { if (this.eventLoopGroup != null) { this.eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); } + long full = droppedFullCount.get(); + long stale = droppedStaleCount.get(); + int availablePermits = semaphore != null ? semaphore.availablePermits() : -1; + if (full > 0 || stale > 0) { + log.warn("[{}][{}] REST API call node destroyed with anomalies: " + + "droppedQueueFull={}, droppedStale={}, dispatched={}, success={}, failure={}, semaphorePermits={}.", + tenantId, nodeId, full, stale, + dispatchedCount.get(), successCount.get(), failureCount.get(), availablePermits); + } else { + log.debug("[{}][{}] REST API call node destroyed. dispatched={}, success={}, failure={}, semaphorePermits={}.", + tenantId, nodeId, dispatchedCount.get(), successCount.get(), failureCount.get(), availablePermits); + } } public void processMessage(TbContext ctx, TbMsg msg, Consumer onSuccess, BiConsumer onFailure) { - try { - if (semaphore != null && !semaphore.tryAcquire(config.getReadTimeoutMs(), TimeUnit.MILLISECONDS)) { - onFailure.accept(msg, new RuntimeException("Timeout during waiting for reply!")); - return; + if (semaphore == null) { + doHttpCall(new PendingTask(ctx, msg, onSuccess, onFailure, 0L)); + return; + } + if (!pendingQueue.offer(new PendingTask(ctx, msg, onSuccess, onFailure, System.nanoTime()))) { + droppedFullCount.incrementAndGet(); + log.debug("[{}][{}] REST API call queue full, dropping msg {}.", tenantId, nodeId, msg.getId()); + maybeReportAnomalies(); + onFailure.accept(msg, new RuntimeException("Max pending requests limit exceeded!")); + return; + } + tryProcess(); + } + + /** + * Tries to acquire one concurrency slot and fire the next queued task. + * Stale messages (whose message pack has expired) are silently dropped. + * Safe to call from any thread under high concurrency. + */ + private void tryProcess() { + while (true) { + if (!semaphore.tryAcquire()) { + return; // all slots are in use; a callback will call tryProcess() when one frees up + } + PendingTask next = pendingQueue.poll(); + if (next == null) { + semaphore.release(); + return; // queue is empty; slot released + } + if (!next.msg().isValid()) { + semaphore.release(); + droppedStaleCount.incrementAndGet(); + log.debug("[{}][{}] Dropping stale msg {} from REST API call queue (queueDepth={}).", + tenantId, nodeId, next.msg().getId(), pendingQueue.size()); + next.onFailure().accept(next.msg(), new RuntimeException("Message is no longer valid. Dropped from queue.")); + maybeReportAnomalies(); + continue; // slot released — loop to check if there's a valid next item } + dispatchedCount.incrementAndGet(); + if (doHttpCall(next)) { + return; // async HTTP call started — its callback will call tryProcess() + } + // synchronous failure — semaphore already released in doHttpCall, loop to try next task + } + } + + private void maybeReportAnomalies() { + long now = System.currentTimeMillis(); + if (now - lastAnomalyReportAt < ANOMALY_REPORT_INTERVAL_MS) { + return; + } + lastAnomalyReportAt = now; + int queueSize = pendingQueue != null ? pendingQueue.size() : 0; + int queueRemaining = pendingQueue != null ? pendingQueue.remainingCapacity() : Integer.MAX_VALUE; + int availablePermits = semaphore != null ? semaphore.availablePermits() : -1; + log.warn("[{}][{}] REST API call node anomalies: droppedQueueFull={}, droppedStale={} " + + "(dispatched={}, success={}, failure={}, queueDepth={}, queueRemaining={}, semaphorePermits={}).", + tenantId, nodeId, + droppedFullCount.get(), droppedStaleCount.get(), + dispatchedCount.get(), successCount.get(), failureCount.get(), + queueSize, queueRemaining, availablePermits); + } - String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); + /** + * Initiates an async HTTP call for the given task. + * + * @return {@code true} if the async subscription was started and the semaphore slot is now + * owned by the callback (which will release it and call {@link #tryProcess()}). + * {@code false} if a synchronous exception occurred before the subscription was + * registered; the semaphore slot has already been released and the caller should + * loop rather than recurse to avoid stack overflow when many queued tasks fail + * synchronously (e.g. misconfigured URL pattern). + */ + private boolean doHttpCall(PendingTask task) { + boolean asyncStarted = false; + try { + String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), task.msg()); List> processedQueryParams; if (config.getQueryParams() != null) { processedQueryParams = config.getQueryParams().stream() .map(param -> { - var processedParamName = TbNodeUtils.processPattern(param.key(), msg); - var processedParamValue = TbNodeUtils.processPattern(param.value(), msg); + var processedParamName = TbNodeUtils.processPattern(param.key(), task.msg()); + var processedParamValue = TbNodeUtils.processPattern(param.value(), task.msg()); return new KeyValueEntry<>(processedParamName, processedParamValue); }) .toList(); @@ -235,39 +355,53 @@ public class TbHttpClient { HttpMethod method = HttpMethod.valueOf(config.getRequestMethod()); URI uri = buildEncodedUri(endpointUrl, processedQueryParams); - RequestBodySpec request = webClient + RequestBodySpec req = webClient .method(method) .uri(uri) - .headers(headers -> prepareHeaders(headers, msg)); + .headers(headers -> prepareHeaders(headers, task.msg())); if ((HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) || HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method)) && !config.isIgnoreRequestBody()) { - request.body(BodyInserters.fromValue(getData(msg, config.isParseToPlainText()))); + req.body(BodyInserters.fromValue(getRequestBody(task.msg()))); } - request - .retrieve() + req.retrieve() .toEntity(String.class) - .subscribe(responseEntity -> { + .publishOn(Schedulers.fromExecutor(task.ctx().getExternalCallExecutor())) + .doFinally(signalType -> { + // Runs exactly once after onComplete, onError, or cancel — the only + // place that releases the permit for the async path. if (semaphore != null) { semaphore.release(); + tryProcess(); } - + }) + .subscribe(responseEntity -> { if (responseEntity.getStatusCode().is2xxSuccessful()) { - onSuccess.accept(processResponse(ctx, msg, responseEntity)); + successCount.incrementAndGet(); + task.onSuccess().accept(processResponse(task.ctx(), task.msg(), responseEntity)); } else { - onFailure.accept(processFailureResponse(msg, responseEntity), null); + failureCount.incrementAndGet(); + task.onFailure().accept(processFailureResponse(task.msg(), responseEntity), null); } }, throwable -> { - if (semaphore != null) { - semaphore.release(); - } - - onFailure.accept(processException(msg, throwable), processThrowable(throwable)); + failureCount.incrementAndGet(); + task.onFailure().accept(processException(task.msg(), throwable), processThrowable(throwable)); }); - } catch (InterruptedException e) { - log.warn("Timeout during waiting for reply!", e); + asyncStarted = true; + return true; + } catch (Exception e) { + failureCount.incrementAndGet(); + task.onFailure().accept(processException(task.msg(), e), processThrowable(e)); + return false; + } finally { + // Synchronous permit release: only when the async pipeline was never registered + // (asyncStarted=false). If it was, doFinally owns the release. + // tryProcess() is intentionally not called here — the caller loops iteratively. + if (!asyncStarted && semaphore != null) { + semaphore.release(); + } } } @@ -275,7 +409,7 @@ public class TbHttpClient { if (origin instanceof WebClientResponseException restClientResponseException && restClientResponseException.getStatusCode().is2xxSuccessful()) { // return cause instead of original exception in case 2xx status code - // this will provide meaningful error message to the user + // this will provide a meaningful error message to the user return new RuntimeException(restClientResponseException.getCause()); } return origin; @@ -340,6 +474,22 @@ public class TbHttpClient { return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri(); } + private Object getRequestBody(TbMsg msg) { + if (StringUtils.isNotBlank(config.getRequestBodyTemplate())) { + boolean escapeJson = !config.isParseToPlainText(); + String processedTemplate = TbNodeUtils.processPattern(config.getRequestBodyTemplate(), msg, escapeJson); + if (config.isParseToPlainText()) { + return processedTemplate; + } + try { + return JacksonUtil.toJsonNode(processedTemplate); + } catch (Exception e) { + throw new RuntimeException("Request body template produced invalid JSON: " + processedTemplate, e); + } + } + return getData(msg, config.isParseToPlainText()); + } + private Object getData(TbMsg tbMsg, boolean parseToPlainText) { String data = tbMsg.getData(); return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data); @@ -363,7 +513,7 @@ public class TbHttpClient { headers.forEach((key, values) -> { if (values != null && !values.isEmpty()) { if (values.size() == 1) { - consumer.accept(key, values.get(0)); + consumer.accept(key, values.getFirst()); } else { consumer.accept(key, JacksonUtil.toString(values)); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java index 5ffe37b102..4120dd7ad8 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java @@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.rest; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -41,7 +42,9 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie version = 4, nodeDescription = "Invoke REST API calls to external REST server", nodeDetails = "Will invoke REST API call GET | POST | PUT | DELETE to external REST server. " + - "Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." + + "Message payload is used as the request body by default. " + + "Optionally, a request body template can be configured with ${metadataKey} and $[messageKey] placeholders. " + + "Configured attributes can be added into Headers from Message Metadata." + " Outbound message will contain response fields " + "(status, statusCode, statusReason and response headers) in the Message Metadata." + " Response body saved in outbound Message payload. " + @@ -56,6 +59,7 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { static final String PARSE_TO_PLAIN_TEXT = "parseToPlainText"; static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "maxInMemoryBufferSizeInKb"; + static final String REQUEST_BODY_TEMPLATE = "requestBodyTemplate"; static final String TRIM_DOUBLE_QUOTES = "trimDoubleQuotes"; protected TbHttpClient httpClient; @@ -63,7 +67,8 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); - var config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class); + TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class); + String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; try { validateFields(config, errorPrefix); @@ -71,7 +76,11 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { throw new TbNodeException(e, true); } - httpClient = new TbHttpClient(config, ctx.getSharedEventLoop()); + TbHttpClientSettings httpClientSettings = ctx.getTbHttpClientSettings(); + httpClient = new TbHttpClient(config, ctx.getSharedEventLoop(), + ctx.getTenantId() != null ? ctx.getTenantId().getId().toString() : "n/a", + ctx.getSelfId() != null ? ctx.getSelfId().getId().toString() : "n/a", + httpClientSettings != null ? httpClientSettings : TbHttpClientSettings.DEFAULT); } @Override @@ -110,12 +119,16 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { ((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256); } case 3: + if (!oldConfiguration.has(REQUEST_BODY_TEMPLATE)) { + hasChanges = true; + ((ObjectNode) oldConfiguration).putNull(REQUEST_BODY_TEMPLATE); + } Set knownProperties = Set.of( "restEndpointUrlPattern", "requestMethod", "headers", "readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText", "enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort", "proxyUser", "proxyPassword", "credentials", "ignoreRequestBody", - "maxInMemoryBufferSizeInKb" + "requestBodyTemplate", "maxInMemoryBufferSizeInKb" ); Iterator fieldNames = oldConfiguration.fieldNames(); while (fieldNames.hasNext()) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java index 9377d83949..0994ec19e3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java @@ -51,6 +51,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration data = metaData.getData(); Assertions.assertEquals(2, data.size()); - Assertions.assertEquals(data.get("Content-Type"), "binary"); - Assertions.assertEquals(data.get("Set-Cookie"), "[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]"); + Assertions.assertEquals("binary", data.get("Content-Type")); + Assertions.assertEquals("[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]", data.get("Set-Cookie")); } } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java index 3d3abde30c..7d0e85b849 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java @@ -28,13 +28,18 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -44,6 +49,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.util.KeyValueEntry; import org.thingsboard.server.common.msg.TbMsg; @@ -52,26 +58,35 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.exception.DataValidationException; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) +@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { - private RuleNode ruleNode; + static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30); @Spy private TbRestApiCallNode restNode; @@ -79,11 +94,11 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { @Mock private TbContext ctx; - private EntityId originator = new DeviceId(Uuids.timeBased()); - private TbMsgMetaData metaData = new TbMsgMetaData(); + private final EntityId originator = new DeviceId(Uuids.timeBased()); + private final TbMsgMetaData metaData = new TbMsgMetaData(); - private RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased()); - private RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased()); + private final RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased()); + private final RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased()); private HttpServer server; @@ -108,7 +123,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { @BeforeEach public void setup() { - ruleNode = new RuleNode(); + RuleNode ruleNode = new RuleNode(); ruleNode.setId(ruleNodeId); ruleNode.setName("Test REST API call node"); lenient().when(ctx.getSelf()).thenReturn(ruleNode); @@ -217,25 +232,22 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { public void deleteRequestWithoutBody() throws IOException, InterruptedException { final CountDownLatch latch = new CountDownLatch(1); final String path = "/path/to/delete"; - setupServer("*", new HttpRequestHandler() { - - @Override - public void handle(HttpRequest request, HttpResponse response, HttpContext context) - throws HttpException, IOException { - try { - assertEquals(request.getRequestLine().getUri(), path, "Request path matches"); - assertTrue(request.containsHeader("Foo"), "Custom header included"); - assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); - response.setStatusCode(200); - latch.countDown(); - } catch (Exception e) { - System.out.println("Exception handling request: " + e.toString()); - e.printStackTrace(); - latch.countDown(); - } + setupServer("*", (request, response, _) -> { + try { + assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); + assertTrue(request.containsHeader("Foo"), "Custom header included"); + assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); + response.setStatusCode(200); + latch.countDown(); + } catch (Exception e) { + System.out.println("Exception handling request: " + e); + e.printStackTrace(); + latch.countDown(); } }); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setRequestMethod("DELETE"); config.setHeaders(Collections.singletonMap("Foo", "Bar")); @@ -259,7 +271,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + verify(ctx, timeout(TIMEOUT)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); assertNotSame(metaData, metadataCaptor.getValue()); assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); @@ -269,31 +281,28 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { public void deleteRequestWithBody() throws IOException, InterruptedException { final CountDownLatch latch = new CountDownLatch(1); final String path = "/path/to/delete"; - setupServer("*", new HttpRequestHandler() { - - @Override - public void handle(HttpRequest request, HttpResponse response, HttpContext context) - throws HttpException, IOException { - try { - assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); - assertTrue(request.containsHeader("Content-Type"), "Content-Type included"); - assertEquals("application/json", - request.getFirstHeader("Content-Type").getValue(), "Content-Type value"); - assertTrue(request.containsHeader("Content-Length"), "Content-Length included"); - assertEquals("2", - request.getFirstHeader("Content-Length").getValue(), "Content-Length value"); - assertTrue(request.containsHeader("Foo"), "Custom header included"); - assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); - response.setStatusCode(200); - latch.countDown(); - } catch (Exception e) { - System.out.println("Exception handling request: " + e.toString()); - e.printStackTrace(); - latch.countDown(); - } + setupServer("*", (request, response, _) -> { + try { + assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); + assertTrue(request.containsHeader("Content-Type"), "Content-Type included"); + assertEquals("application/json", + request.getFirstHeader("Content-Type").getValue(), "Content-Type value"); + assertTrue(request.containsHeader("Content-Length"), "Content-Length included"); + assertEquals("2", + request.getFirstHeader("Content-Length").getValue(), "Content-Length value"); + assertTrue(request.containsHeader("Foo"), "Custom header included"); + assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); + response.setStatusCode(200); + latch.countDown(); + } catch (Exception e) { + System.out.println("Exception handling request: " + e); + e.printStackTrace(); + latch.countDown(); } }); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setRequestMethod("DELETE"); config.setHeaders(Collections.singletonMap("Foo", "Bar")); @@ -317,12 +326,282 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + verify(ctx, timeout(TIMEOUT)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); assertNotSame(metaData, metadataCaptor.getValue()); assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); } + @Test + public void givenForceAckTrue_whenOnMsgAndServerReturns200_thenAckedImmediatelyAndEnqueuedForTellNext() throws IOException { + final String path = "/path/to/get"; + setupServer("*", new HttpRequestHandler() { + @Override + public void handle(HttpRequest request, HttpResponse response, HttpContext context) + throws HttpException, IOException { + response.setStatusCode(200); + } + }); + + TbMsg transformedMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + + given(ctx.isExternalNodeForceAck()).willReturn(true); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + given(ctx.transformMsg(any(), any(), any())).willReturn(transformedMsg); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("GET"); + config.setIgnoreRequestBody(true); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + verify(ctx).ack(msg); + verify(ctx, timeout(TIMEOUT)).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS)); + verify(ctx, never()).tellSuccess(any()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void givenMaxParallelRequestsCountAndBadUrl_whenOnMsg_thenSemaphoreIsReleasedAndFailureReported(boolean forceAck) throws IOException { + given(ctx.isExternalNodeForceAck()).willReturn(forceAck); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setMaxParallelRequestsCount(1); + config.setRestEndpointUrlPattern(""); + initWithConfig(config); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertThat(restNode.httpClient.getSemaphore().availablePermits()).isEqualTo(1); + if (forceAck) { + verify(ctx).enqueueForTellFailure(any(), any(Throwable.class)); + } else { + verify(ctx).tellFailure(any(), any()); + } + } + + @Test + public void givenMaxPendingRequestsExceeded_whenOnMsg_thenFailsImmediatelyAndQueuedRequestFiresAfterSlotOpens() throws IOException, InterruptedException { + CountDownLatch releaseResponse = new CountDownLatch(1); + setupServer("*", (request, response, context) -> { + try { + releaseResponse.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + response.setStatusCode(200); + }); + + given(ctx.isExternalNodeForceAck()).willReturn(false); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + // Simulate server-level cap: maxPendingRequests=1 via TbHttpClientSettings + given(ctx.getTbHttpClientSettings()).willReturn(new TbHttpClientSettings() { + @Override public int getMaxParallelRequests() { return 0; } + @Override public int getMaxPendingRequests() { return 1; } + @Override public int getPoolMaxConnections() { return 0; } + }); + TbMsg transformedMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .data(TbMsg.EMPTY_JSON_OBJECT) + .build(); + given(ctx.transformMsg(any(), any(), any())).willReturn(transformedMsg); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setMaxParallelRequestsCount(1); + config.setRequestMethod("GET"); + config.setIgnoreRequestBody(true); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d/path", server.getLocalPort())); + initWithConfig(config); + + TbMsg msg1 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator) + .copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build(); + TbMsg msg2 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator) + .copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build(); + TbMsg msg3 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator) + .copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build(); + + restNode.onMsg(ctx, msg1); // fires immediately (semaphore acquired) + restNode.onMsg(ctx, msg2); // queues (semaphore exhausted, queue has room) + restNode.onMsg(ctx, msg3); // fails immediately (queue full — server-level maxPendingRequests=1) + + verify(ctx, timeout(TIMEOUT)).tellFailure(any(), any()); + + releaseResponse.countDown(); + verify(ctx, timeout(TIMEOUT).times(2)).tellSuccess(any()); + } + + @Test + public void postRequestWithBodyTemplate() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/token"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + config.setRequestBodyTemplate("{\"grant_type\":\"client_credentials\",\"client_id\":\"${clientId}\",\"value\":\"$[token]\"}"); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + metaData.putValue("clientId", "my-client-123"); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"token\":\"abc-xyz\"}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + assertEquals("{\"grant_type\":\"client_credentials\",\"client_id\":\"my-client-123\",\"value\":\"abc-xyz\"}", capturedBody.get()); + } + + @Test + public void postRequestWithBodyTemplateAndParseToPlainText() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/text"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + config.setParseToPlainText(true); + config.setRequestBodyTemplate("Hello ${name}, your token is $[token]!"); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + metaData.putValue("name", "World"); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"token\":\"abc-xyz\"}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + assertEquals("Hello World, your token is abc-xyz!", capturedBody.get()); + } + + @Test + public void postRequestWithBodyTemplateEscapesJsonSpecialChars() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/token"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + config.setRequestBodyTemplate("{\"name\":\"${userName}\",\"desc\":\"$[description]\"}"); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + metaData.putValue("userName", "John \"Doe\""); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"description\":\"line1\\nline2\"}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + assertEquals("{\"name\":\"John \\\"Doe\\\"\",\"desc\":\"line1\\nline2\"}", capturedBody.get()); + } + + @Test + public void postRequestWithEmptyBodyTemplateUsesMessageData() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/data"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + // requestBodyTemplate is null by default — should use msg.getData() + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"temperature\":25}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); + verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + assertEquals("{\"temperature\":25}", capturedBody.get()); + } + + private void setupServerWithBodyCapture(AtomicReference capturedBody, CountDownLatch latch) throws IOException { + setupServer("*", (request, response, _) -> { + try { + if (request instanceof org.apache.http.HttpEntityEnclosingRequest entityRequest) { + InputStream is = entityRequest.getEntity().getContent(); + capturedBody.set(new String(is.readAllBytes(), StandardCharsets.UTF_8)); + } + response.setStatusCode(200); + latch.countDown(); + } catch (Exception e) { + e.printStackTrace(); + latch.countDown(); + } + }); + } + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of( Arguments.of(0, @@ -339,7 +618,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + - "\"maxInMemoryBufferSizeInKb\": 256}"), + "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"), Arguments.of(1, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -355,7 +634,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + - "\"maxInMemoryBufferSizeInKb\": 256}"), + "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"), Arguments.of(2, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -370,7 +649,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + - "\"maxInMemoryBufferSizeInKb\": 256}"), + "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"), Arguments.of(3, """ { "restEndpointUrlPattern": "http://localhost/api", @@ -419,6 +698,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "credentials": { "type": "anonymous" }, + "requestBodyTemplate": null, "maxInMemoryBufferSizeInKb": 256 }""") ); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java index 7b00d249ff..062e2d0a2e 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java @@ -51,7 +51,7 @@ public class TbSendRestApiCallReplyNodeTest { private TbSendRestApiCallReplyNode node; private TbSendRestApiCallReplyNodeConfiguration config; - + @Mock private TbContext ctxMock; @Mock @@ -140,4 +140,5 @@ public class TbSendRestApiCallReplyNodeTest { TbMsg.EMPTY_STRING, "Request body is empty!") ); } + } diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index fa19b204d0..03416b1c32 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -56,7 +56,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index f92db80cc4..12f5bc60d8 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -67,6 +67,7 @@ import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { MatDialogRef } from '@angular/material/dialog'; import { TbUnit } from '@shared/models/unit.models'; import { UnitService } from '@core/services/unit.service'; +import { HttpOptionsResult, HttpUploadOptionsResult, QueryParams, RequestConfig } from '@core/http/http-utils'; export interface TimewindowFunctions { onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void; @@ -94,6 +95,14 @@ export interface IWidgetUtils { getEntityDetailsPageURL: (id: string, entityType: EntityType) => string; } +export interface IWidgetHttpUtils { + defaultHttpOptions: (ignoreLoading?: boolean, ignoreErrors?: boolean, resendRequest?: boolean, queryParams?: QueryParams) => HttpOptionsResult; + defaultHttpOptionsFromConfig: (config?: RequestConfig) => HttpOptionsResult; + defaultHttpOptionsFromParams: (queryParams?: QueryParams, config?: RequestConfig) => HttpOptionsResult; + defaultHttpUploadOptions: (ignoreLoading?: boolean, ignoreErrors?: boolean, resendRequest?: boolean, queryParams?: QueryParams) => HttpUploadOptionsResult; + createDefaultHttpOptions: (queryParamsOrConfig?: QueryParams | RequestConfig, config?: RequestConfig) => HttpOptionsResult; +} + export interface PlaceMapItemActionData { action: WidgetAction | WidgetActionDescriptor; additionalParams?: any; diff --git a/ui-ngx/src/app/core/http/alarm-rules.service.ts b/ui-ngx/src/app/core/http/alarm-rules.service.ts new file mode 100644 index 0000000000..18a51115f3 --- /dev/null +++ b/ui-ngx/src/app/core/http/alarm-rules.service.ts @@ -0,0 +1,73 @@ +/// +/// 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. +/// + +import { Injectable } from '@angular/core'; +import { defaultHttpOptionsFromConfig, defaultHttpOptionsFromParams, RequestConfig } from './http-utils'; +import { Observable } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { PageData } from '@shared/models/page/page-data'; +import { + CalculatedFieldAlarmRule, + CalculatedFieldAlarmRuleInfo, + CalculatedFieldsQuery, + CalculatedFieldTestScriptInputParams +} from '@shared/models/calculated-field.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityTestScriptResult } from '@shared/models/entity.models'; +import { CalculatedFieldEventBody } from '@shared/models/event.models'; + +@Injectable({ + providedIn: 'root' +}) +export class AlarmRulesService { + + constructor( + private http: HttpClient + ) { } + + public getAlarmRuleById(alarmRuleId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/alarm/rule/${alarmRuleId}`, defaultHttpOptionsFromConfig(config)); + } + + public saveAlarmRule(alarmRule: CalculatedFieldAlarmRule, config?: RequestConfig): Observable { + return this.http.post('/api/alarm/rule', alarmRule, defaultHttpOptionsFromConfig(config)); + } + + public deleteAlarmRule(alarmRuleId: string, config?: RequestConfig): Observable { + return this.http.delete(`/api/alarm/rule/${alarmRuleId}`, defaultHttpOptionsFromConfig(config)); + } + + public getAlarmRules(pageLink: PageLink, query: CalculatedFieldsQuery, config?: RequestConfig): Observable> { + return this.http.get>(`/api/alarm/rules${pageLink.toQuery()}`, defaultHttpOptionsFromParams(query, config)); + } + + public getAlarmRulesByEntityId({ entityType, id }: EntityId, pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/alarm/rules/${entityType}/${id}${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } + + public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable { + return this.http.post('/api/alarm/rule/testScript', inputParams, defaultHttpOptionsFromConfig(config)); + } + + public getLatestAlarmRuleDebugEvent(id: string, config?: RequestConfig): Observable { + return this.http.get(`/api/alarm/rule/${id}/debug`, defaultHttpOptionsFromConfig(config)); + } + + public getAlarmRuleNames(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/alarm/rules/names${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } +} diff --git a/ui-ngx/src/app/core/http/http-utils.ts b/ui-ngx/src/app/core/http/http-utils.ts index 8b90821c09..98f65f924a 100644 --- a/ui-ngx/src/app/core/http/http-utils.ts +++ b/ui-ngx/src/app/core/http/http-utils.ts @@ -15,7 +15,7 @@ /// import { InterceptorHttpParams } from '../interceptors/interceptor-http-params'; -import { HttpHeaders } from '@angular/common/http'; +import { HttpHeaders, HttpParams } from '@angular/common/http'; import { InterceptorConfig } from '../interceptors/interceptor-config'; export type QueryParams = { [param:string]: any }; @@ -27,6 +27,15 @@ export interface RequestConfig { queryParams?: QueryParams; } +export interface HttpOptionsResult { + headers: HttpHeaders; + params: HttpParams; +} + +export interface HttpUploadOptionsResult { + params: HttpParams; +} + export function hasRequestConfig(config?: any): boolean { if (!config) { return false; diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index a520eea37b..8f721b87dd 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -16,6 +16,7 @@ import * as AngularAnimations from '@angular/animations'; import * as AngularCore from '@angular/core'; +import * as AngularCoreRxjsInterop from '@angular/core/rxjs-interop'; import * as AngularCommon from '@angular/common'; import * as AngularForms from '@angular/forms'; import * as AngularPlatformBrowser from '@angular/platform-browser'; @@ -355,6 +356,7 @@ class ModulesMap implements IModulesMap { private modulesMap: {[key: string]: any} = { '@angular/animations': AngularAnimations, '@angular/core': this.angularCoreModule20to18Patch(AngularCore), + '@angular/core/rxjs-interop': AngularCoreRxjsInterop, '@angular/common': AngularCommon, '@angular/common/http': HttpClientModule, '@angular/forms': AngularForms, diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts index 62cee00513..10793723db 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts @@ -21,11 +21,15 @@ import { AppState } from '@core/core.state'; import { FormGroup } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@shared/components/dialog.component'; -import { CalculatedField, CalculatedFieldArgument, CalculatedFieldType } from '@shared/models/calculated-field.models'; +import { + CalculatedFieldAlarmRule, + CalculatedFieldArgument, + CalculatedFieldType +} from '@shared/models/calculated-field.models'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ScriptLanguage } from '@shared/models/rule-node.models'; -import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { AlarmRulesService } from '@core/http/alarm-rules.service'; import { EntityId } from '@shared/models/id/entity-id'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; import { COMMA, ENTER, SEMICOLON } from "@angular/cdk/keycodes"; @@ -49,13 +53,13 @@ import { AssetInfo } from '@shared/models/asset.models'; import { DeviceInfo } from '@shared/models/device.models'; export interface AlarmRuleDialogData { - value?: CalculatedField; + value?: CalculatedFieldAlarmRule; buttonTitle: string; entityId: EntityId; tenantId: string; entityName?: string; ownerId: EntityId; - additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; + additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedFieldAlarmRule) => void>; isDirty?: boolean; getTestScriptDialogFn: AlarmRuleTestScriptFn, } @@ -67,7 +71,7 @@ export interface AlarmRuleDialogData { encapsulation: ViewEncapsulation.None, standalone: false }) -export class AlarmRuleDialogComponent extends DialogComponent { +export class AlarmRuleDialogComponent extends DialogComponent { @ViewChild('entitySelect') entitySelect!: EntitySelectComponent; @@ -96,8 +100,8 @@ export class AlarmRuleDialogComponent extends DialogComponent, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: AlarmRuleDialogData, - protected dialogRef: MatDialogRef, - private calculatedFieldsService: CalculatedFieldsService, + protected dialogRef: MatDialogRef, + private alarmRulesService: AlarmRulesService, private destroyRef: DestroyRef, private cfFormService: CalculatedFieldFormService) { super(store, router, dialogRef); @@ -159,8 +163,8 @@ export class AlarmRuleDialogComponent extends DialogComponent this.dialogRef.close(calculatedField), diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index 039ee7d5fd..f5043bdcee 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -21,7 +21,7 @@ import { EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models'; -import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { TranslateService } from '@ngx-translate/core'; import { Direction } from '@shared/models/page/sort-order'; import { MatDialog } from '@angular/material/dialog'; @@ -35,15 +35,14 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { DestroyRef, Renderer2 } from '@angular/core'; import { EntityDebugSettings } from '@shared/models/entity.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { AlarmRulesService } from '@core/http/alarm-rules.service'; import { catchError, filter, first, switchMap, tap } from 'rxjs/operators'; import { ArgumentEntityType, ArgumentType, - CalculatedField, CalculatedFieldAlarmRule, + CalculatedFieldAlarmRuleInfo, CalculatedFieldEventArguments, - CalculatedFieldInfo, CalculatedFieldsQuery, CalculatedFieldType, getCalculatedFieldArgumentsEditorCompleter, @@ -72,7 +71,7 @@ import { Router } from '@angular/router'; import { EntityAction } from '@home/models/entity/entity-component.models'; import { AlarmRulesComponent } from '@home/components/alarm-rules/alarm-rules.component'; -type AlarmRuleTableEntity = CalculatedField | CalculatedFieldInfo; +export type AlarmRuleTableEntity = CalculatedFieldAlarmRule | CalculatedFieldAlarmRuleInfo; export class AlarmRulesTableConfig extends EntityTableConfig { @@ -84,7 +83,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.fetchCalculatedFields(pageLink); this.addEntity = this.getCalculatedAlarmDialog.bind(this); - this.loadEntity = id => this.calculatedFieldsService.getCalculatedFieldById(id.id); - this.saveEntity = (alarmRule) => this.calculatedFieldsService.saveCalculatedField(alarmRule); + this.loadEntity = id => this.alarmRulesService.getAlarmRuleById(id.id); + this.saveEntity = (alarmRule) => this.alarmRulesService.saveAlarmRule(alarmRule); this.deleteEntityTitle = (field) => this.translate.instant('alarm-rule.delete-title', {title: field.name}); this.deleteEntityContent = () => this.translate.instant('alarm-rule.delete-text'); this.deleteEntitiesTitle = count => this.translate.instant('alarm-rule.delete-multiple-title', {count}); this.deleteEntitiesContent = () => this.translate.instant('alarm-rule.delete-multiple-text'); - this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id); + this.deleteEntity = id => this.alarmRulesService.deleteAlarmRule(id.id); this.onEntityAction = action => this.onCFAction(action); @@ -159,9 +158,9 @@ export class AlarmRulesTableConfig extends EntityTableConfig('name', 'alarm-rule.alarm-type', '33%', entity => this.utilsService.customTranslation(entity.name, entity.name))); if (this.pageMode) { - this.columns.push(new EntityTableColumn('entityType', 'entity.entity-type', '10%', + this.columns.push(new EntityTableColumn('entityType', 'entity.entity-type', '10%', entity => this.translate.instant(entityTypeTranslations.get(entity.entityId.entityType).type))); - this.columns.push(new EntityLinkTableColumn('entityName', 'entity.entity', '33%', + this.columns.push(new EntityLinkTableColumn('entityName', 'entity.entity', '33%', entity => this.utilsService.customTranslation(entity.entityName, entity.entityName), entity => getEntityDetailsPageURL(entity.entityId?.id, entity.entityId?.entityType as EntityType), false)); } @@ -216,8 +215,8 @@ export class AlarmRulesTableConfig extends EntityTableConfig> { return this.pageMode ? - this.calculatedFieldsService.getCalculatedFields(pageLink, {types: [CalculatedFieldType.ALARM], ...this.alarmRuleFilterConfig}) : - this.calculatedFieldsService.getCalculatedFieldsByEntityId(this.entityId, pageLink, CalculatedFieldType.ALARM); + this.alarmRulesService.getAlarmRules(pageLink, this.alarmRuleFilterConfig) : + this.alarmRulesService.getAlarmRulesByEntityId(this.entityId, pageLink); } onOpenDebugConfig($event: Event, calculatedField: AlarmRuleTableEntity): void { @@ -260,7 +259,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig { + private getCalculatedAlarmDialog(value?: AlarmRuleTableEntity, buttonTitle = 'action.add', isDirty = false): Observable { const entityId = this.entityId || value?.entityId; - const entityName = this.entityName || (value as CalculatedFieldInfo)?.entityName; - return this.dialog.open(AlarmRuleDialogComponent, { + const entityName = this.entityName || (value as CalculatedFieldAlarmRuleInfo)?.entityName; + return this.dialog.open(AlarmRuleDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { @@ -353,14 +352,14 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.getCalculatedAlarmDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)), filter(Boolean), - switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), + switchMap(calculatedField => this.alarmRulesService.saveAlarmRule(calculatedField)), filter(Boolean), takeUntilDestroyed(this.destroyRef) ) .subscribe(() => this.updateData()); } - private updateImportedCalculatedField(calculatedField: CalculatedField): CalculatedField { + private updateImportedCalculatedField(calculatedField: CalculatedFieldAlarmRule): CalculatedFieldAlarmRule { if (calculatedField.type === CalculatedFieldType.ALARM) { calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { const arg = calculatedField.configuration.arguments[key]; @@ -375,48 +374,44 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })), + this.alarmRulesService.getAlarmRuleById(id).pipe( + switchMap(field => this.alarmRulesService.saveAlarmRule({ ...field, debugSettings })), catchError(() => of(null)), takeUntilDestroyed(this.destroyRef), ).subscribe(() => this.updateData()); } - private getTestScriptDialog(calculatedField: AlarmRuleTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { - if (calculatedField.type === CalculatedFieldType.ALARM) { - const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { - const type = calculatedField.configuration.arguments[key].refEntityKey.type; - acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) - ? {...argumentsObj[key], type} - : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; - return acc; - }, {}); - return this.dialog.open(CalculatedFieldScriptTestDialogComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], - data: { - arguments: resultArguments, - expression, - argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), - argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), - openCalculatedFieldEdit + getTestScriptDialog(calculatedField: AlarmRuleTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { + const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const type = calculatedField.configuration.arguments[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? {...argumentsObj[key], type} + : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), + openCalculatedFieldEdit + } + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => { + if (openCalculatedFieldEdit) { + this.editCalculatedField(null, { + entityId: this.entityId, ...calculatedField, + configuration: {...calculatedField.configuration, expression} as any + }, true) } - }).afterClosed() - .pipe( - filter(Boolean), - tap(expression => { - if (openCalculatedFieldEdit) { - this.editCalculatedField(null, { - entityId: this.entityId, ...calculatedField, - configuration: {...calculatedField.configuration, expression} as any - }, true) - } - }), - ); - } else { - return of(null); - } + }), + ); } private openCalculatedField($event: Event, entity: AlarmRuleTableEntity) { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts index a13aa2fac7..ce0aecb598 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts @@ -30,7 +30,7 @@ import { TranslateService } from '@ngx-translate/core'; import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { AlarmRulesService } from '@core/http/alarm-rules.service'; import { ImportExportService } from '@shared/import-export/import-export.service'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { DatePipe } from '@angular/common'; @@ -59,7 +59,7 @@ export class AlarmRulesTableComponent { pageMode: boolean = false; - constructor(private calculatedFieldsService: CalculatedFieldsService, + constructor(private alarmRulesService: AlarmRulesService, private translate: TranslateService, private dialog: MatDialog, private store: Store, @@ -77,7 +77,7 @@ export class AlarmRulesTableComponent { effect(() => { if (this.active() || this.pageMode) { this.alarmRulesTableConfig = new AlarmRulesTableConfig( - this.calculatedFieldsService, + this.alarmRulesService, this.translate, this.dialog, this.datePipe, diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html index c883b20693..daee0a6e8c 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html @@ -142,6 +142,7 @@ placeholder="{{ 'alarm-rule.alarm-rule-relation-types-list' | translate }}" hint="{{ 'alarm-rule.alarm-rule-relation-types-list-hint' | translate }}" [predefinedValues]="predefinedTypeValues" + subscriptSizing="dynamic" allowUserValue formControlName="propagateRelationTypes"> diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts index 13c388675c..740dd30440 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts @@ -22,19 +22,19 @@ import { FormBuilder, FormGroup } from '@angular/forms'; import { EntityType } from '@shared/models/entity-type.models'; import { TranslateService } from '@ngx-translate/core'; import { + CalculatedFieldAlarmRuleConfiguration, + CalculatedFieldAlarmRuleInfo, CalculatedFieldArgument, - CalculatedFieldConfiguration, - CalculatedFieldInfo, CalculatedFieldType } from '@shared/models/calculated-field.models'; import { EntityId } from '@shared/models/id/entity-id'; import { BaseData } from '@shared/models/base-data'; import { Observable } from 'rxjs'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; -import { - CalculatedFieldsTableConfig, - CalculatedFieldsTableEntity -} from '@home/components/calculated-fields/calculated-fields-table-config'; +import type { + AlarmRulesTableConfig, + AlarmRuleTableEntity +} from '@home/components/alarm-rules/alarm-rules-table-config'; import { TenantId } from '@shared/models/id/tenant-id'; import { StringItemsOption } from '@shared/components/string-items-list.component'; import { RelationTypes } from '@shared/models/relation.models'; @@ -56,7 +56,7 @@ import { EntityService } from '@core/http/entity.service'; styleUrls: ['./alarm-rule-dialog.component.scss'], standalone: false }) -export class AlarmRulesComponent extends EntityComponent { +export class AlarmRulesComponent extends EntityComponent { @Input() standalone = false; @@ -75,8 +75,8 @@ export class AlarmRulesComponent extends EntityComponent, protected translate: TranslateService, - @Inject('entity') protected entityValue: CalculatedFieldInfo, - @Inject('entitiesTableConfig') protected entitiesTableConfigValue: CalculatedFieldsTableConfig, + @Inject('entity') protected entityValue: CalculatedFieldAlarmRuleInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: AlarmRulesTableConfig, protected fb: FormBuilder, protected cd: ChangeDetectorRef, private entityService: EntityService) { @@ -93,21 +93,14 @@ export class AlarmRulesComponent extends EntityComponent this.entitiesTableConfig.additionalDebugActionConfig.action( - { id: this.entity.id, ...this.entityFormValue() }, false, - (expression) => { - if (expression) { - this.entityForm.get('configuration').setValue({...this.entityFormValue().configuration, expression}); - this.entityForm.get('configuration').markAsDirty(); - } - }), + action: () => this.entitiesTableConfig.additionalDebugActionConfig.action({id: this.entity.id, ...this.entityFormValue()}) }; get entityId(): EntityId { return this.entityForm.get('entityId').value; } - get entitiesTableConfig(): CalculatedFieldsTableConfig { + get entitiesTableConfig(): AlarmRulesTableConfig { return this.entitiesTableConfigValue; } @@ -115,12 +108,12 @@ export class AlarmRulesComponent extends EntityComponent } - + } diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss index c8de20ad36..1aeded1767 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss @@ -40,7 +40,11 @@ margin-left: 12px; margin-right: 20px; &.inline-icon { - margin-left: 0; + margin-left: 8px; margin-right: 8px; + padding: 6px 0 !important; + } + &.mat-icon { + font-size: 28px !important; } } diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.html b/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.html index 769c553635..41d5d69edb 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.html @@ -33,18 +33,19 @@
-
+
dashboard.select-existing
-
+
dashboard.create-new - + dashboard.new-dashboard-title diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html index f53b87ad9d..9ac6ac1846 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html @@ -37,7 +37,7 @@ } - @if (attributeScope !== attributeScopeTypes.CLIENT_SCOPE) { + @if (attributeScope !== attributeScopeTypes.CLIENT_SCOPE && !disableAddTelemetry) {
- + attribute.delete-timeseries.strategy @for (strategy of strategiesTranslationsMap.keys(); track strategy) { @@ -37,13 +37,13 @@ @if (isPeriodStrategy()) {
- + attribute.delete-timeseries.start-time - + attribute.delete-timeseries.ends-on diff --git a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss index c42ab55735..4a5969afe7 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss @@ -26,7 +26,7 @@ display: flex; flex-direction: column; gap: 16px; - padding-top: 0; + padding-top: 4px; } .tb-select-interval { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html index ad0aeb097c..58cb21253d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html @@ -31,7 +31,7 @@ }
- + dashboard.state-name @if (stateFormGroup.get('name').hasError('required')) { @@ -40,7 +40,7 @@ } - + dashboard.state-id @if (stateFormGroup.get('id').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials.component.html b/ui-ngx/src/app/modules/home/components/device/device-credentials.component.html index fb9332018a..3afadca7b4 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials.component.html +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials.component.html @@ -62,7 +62,7 @@ @case (deviceCredentialsType.X509_CERTIFICATE) { device.certificate-pem-format - + @if (deviceCredentialsFormGroup.get('credentialsValue').value) { + {{ column.title | translate}} {{ 'event.all-events' | translate}} @@ -33,7 +33,7 @@ } @case (isNumberFields(column.key)) { - + {{ column.title | translate}} @if (eventFilterFormGroup.get(column.key).hasError('min')) { @@ -50,13 +50,13 @@ } @case ('errorStr') { - + {{ column.title | translate}} } @default { - + {{ column.title | translate}} diff --git a/ui-ngx/src/app/modules/home/components/event/event-table-header.component.html b/ui-ngx/src/app/modules/home/components/event/event-table-header.component.html index d78c466397..df31150835 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table-header.component.html +++ b/ui-ngx/src/app/modules/home/components/event/event-table-header.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + event.event-type diff --git a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts index 367aced7c1..9891a4f0b3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts @@ -217,8 +217,6 @@ export class AddDeviceProfileDialogComponent extends case 1: return 'device-profile.transport-configuration'; case 2: - return 'device-profile.alarm-rules'; - case 3: return 'device-profile.device-provisioning'; } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index f73e7a30a1..14c6e53546 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -21,7 +21,7 @@ @for (type of deviceProvisionTypes; track type) { - {{ getDeviceProvisionType(type) | translate }} + {{deviceProvisionTypeTranslateMap.get(type) | translate}} } @@ -31,10 +31,10 @@ @switch (provisionConfigurationFormGroup.get('type').value) { @case (deviceProvisionType.ALLOW_CREATE_NEW_DEVICES) { - + } @case (deviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES) { - + } @case (deviceProvisionType.X509_CERTIFICATE_CHAIN) {
@@ -71,38 +71,38 @@ } } + +
+ + device-profile.provision-device-key + + + @if (provisionConfigurationFormGroup.get('provisionDeviceKey').hasError('required')) { + {{ 'device-profile.provision-device-key-required' | translate }} + } + + + device-profile.provision-device-secret + + + @if (provisionConfigurationFormGroup.get('provisionDeviceSecret').hasError('required')) { + {{ 'device-profile.provision-device-secret-required' | translate }} + } + +
+
- -
- - device-profile.provision-device-key - - - @if (provisionConfigurationFormGroup.get('provisionDeviceKey').hasError('required')) { - {{ 'device-profile.provision-device-key-required' | translate }} - } - - - device-profile.provision-device-secret - - - @if (provisionConfigurationFormGroup.get('provisionDeviceSecret').hasError('required')) { - {{ 'device-profile.provision-device-secret-required' | translate }} - } - -
-
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 4065dc602d..05aef4cfbb 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -62,7 +62,7 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu provisionConfigurationFormGroup: UntypedFormGroup; deviceProvisionType = DeviceProvisionType; - deviceProvisionTypes = Object.keys(DeviceProvisionType); + deviceProvisionTypes = Object.values(DeviceProvisionType); deviceProvisionTypeTranslateMap = deviceProvisionTypeTranslationMap; private requiredValue: boolean; @@ -213,8 +213,4 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup.get('provisionDeviceKey').reset({value: null, disabled: true}, {emitEvent: false}); } } - - getDeviceProvisionType(type: string): string { - return this.deviceProvisionTypeTranslateMap.get(type as DeviceProvisionType); - } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html index e2ac4da12b..1ad031c3a9 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html @@ -25,7 +25,7 @@
- + @for (attributeName of attributeNames; track attributeName) { } - + @if (nameValueControl.get('value').hasError('required')) { @@ -68,8 +68,7 @@
} -
device-profile.lwm2m.no-attributes-set
+
device-profile.lwm2m.no-attributes-set
@if (!disabled && isAddEnabled) {
@for (mappingConfig of mappingsConfigFormArray.controls; track mappingConfig; let isLast = $last) { -
+
- + @for (dataType of dataTypes; track dataType) { @@ -43,7 +42,7 @@ } - + @if (mappingConfig.get('key').hasError('required')) { @@ -51,7 +50,7 @@ } - + @if (mappingConfig.get('oid').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html index 7b639f134c..4d7b127aa3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + tenant-profile.tenant-profile (); diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.html index ca553da0b2..3a53b4cbc9 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.html @@ -50,7 +50,7 @@
- + tenant-profile.name @if (entityForm.get('name').hasError('required')) { @@ -103,9 +103,9 @@
- + tenant-profile.description - +
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 7100fce348..ddd159388a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -21,7 +21,7 @@ {{ 'tenant-profile.entities' | translate }} tenant-profile.unlimited
- + tenant-profile.maximum-devices - + tenant-profile.maximum-dashboards
- + tenant-profile.maximum-assets - + tenant-profile.maximum-users
- + tenant-profile.maximum-customers - + tenant-profile.maximum-rule-chains
- + tenant-profile.maximum-edges tenant-profile.unlimited
- + tenant-profile.max-r-e-executions - + tenant-profile.max-transport-messages
- + tenant-profile.max-j-s-executions - + tenant-profile.max-tbel-executions
- + tenant-profile.max-rule-node-executions-per-message - + tenant-profile.max-transport-data-points tenant-profile.unlimited
- + tenant-profile.max-calculated-fields - + tenant-profile.max-data-points-per-rolling-arg
- + tenant-profile.max-arguments-per-cf
- + tenant-profile.max-state-size - + tenant-profile.max-value-argument-size
- + tenant-profile.max-related-level-per-argument - + tenant-profile.min-allowed-scheduled-update-interval
- + tenant-profile.min-allowed-aggregation-interval - + tenant-profile.min-allowed-deduplication-interval
- + tenant-profile.intermediate-aggregation-interval - + tenant-profile.reevaluation-check-interval
- + tenant-profile.relation-search-entity-limit tenant-profile.unlimited
- + tenant-profile.max-d-p-storage-days - + tenant-profile.alarms-ttl-days
- + tenant-profile.default-storage-ttl-days - + tenant-profile.rpc-ttl-days
- + tenant-profile.queue-stats-ttl-days - + tenant-profile.rule-engine-exceptions-ttl-days @if (tenantProfileConfigurationForm.get('smsEnabled').value) { + appearance="outline" subscriptSizing="dynamic"> tenant-profile.max-sms }
- + tenant-profile.max-emails - + tenant-profile.max-created-alarms
- + tenant-profile.alarms-reevaluation-interval
- + tenant-profile.maximum-debug-duration-min tenant-profile.unlimited
- + tenant-profile.maximum-resources-sum-data-size - + tenant-profile.maximum-ota-packages-sum-data-size
- + tenant-profile.maximum-resource-size tenant-profile.unlimited
- + tenant-profile.ws-limit-max-sessions-per-tenant @if (tenantProfileConfigurationForm.get('maxWsSessionsPerTenant').hasError('min')) { @@ -811,7 +811,7 @@ } - + tenant-profile.ws-limit-max-subscriptions-per-tenant @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerTenant').hasError('min')) { @@ -822,7 +822,7 @@
- + tenant-profile.ws-limit-max-sessions-per-customer @if (tenantProfileConfigurationForm.get('maxWsSessionsPerCustomer').hasError('min')) { @@ -831,7 +831,7 @@ } - + tenant-profile.ws-limit-max-subscriptions-per-customer @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerCustomer').hasError('min')) { @@ -849,7 +849,7 @@
- + tenant-profile.ws-limit-max-sessions-per-public-user @if (tenantProfileConfigurationForm.get('maxWsSessionsPerPublicUser').hasError('min')) { @@ -858,7 +858,7 @@ } - + tenant-profile.ws-limit-max-subscriptions-per-public-user @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerPublicUser').hasError('min')) { @@ -869,7 +869,7 @@
- + tenant-profile.ws-limit-max-sessions-per-regular-user @if (tenantProfileConfigurationForm.get('maxWsSessionsPerRegularUser').hasError('min')) { @@ -878,7 +878,7 @@ } - + tenant-profile.ws-limit-max-subscriptions-per-regular-user @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerRegularUser').hasError('min')) { @@ -889,7 +889,7 @@
- + tenant-profile.ws-limit-queue-per-session @if (tenantProfileConfigurationForm.get('wsMsgQueueLimitPerSession').hasError('min')) { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html index d24cc15a4b..159dcc6540 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html @@ -22,7 +22,7 @@
tenant-profile.rate-limits.and-also-less-than
}
- + tenant-profile.rate-limits.number-of-messages @@ -33,7 +33,7 @@ tenant-profile.rate-limits.number-of-messages-min } - + tenant-profile.rate-limits.per-seconds diff --git a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html index de7b1de846..94803a8739 100644 --- a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html +++ b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html @@ -16,7 +16,7 @@ -->
- + admin.queue-name @if (queueFormGroup.get('name').hasError('required')) { @@ -62,7 +62,7 @@ @if (hideBatchSize) {
- + queue.batch-size @if (queueFormGroup.get('submitStrategy.batchSize').hasError('required')) { @@ -107,7 +107,7 @@
- + queue.retries @@ -123,7 +123,7 @@ } - + queue.failure-percentage @@ -147,7 +147,7 @@ } - + queue.pause-between-retries @@ -163,7 +163,7 @@ } - + queue.max-pause-between-retries @@ -194,7 +194,7 @@
- + queue.poll-interval @if (queueFormGroup.get('pollInterval').hasError('required')) { @@ -209,7 +209,7 @@ } - + queue.partitions @if (queueFormGroup.get('partitions').hasError('required')) { @@ -232,7 +232,7 @@ {{ 'queue.consumer-per-partition' | translate }} - + queue.processing-timeout @if (queueFormGroup.get('packProcessingTimeout').hasError('required')) { @@ -257,14 +257,14 @@ {{ 'queue.duplicate-msg-to-all-partitions' | translate }} - + queue.custom-properties - + queue.custom-properties-hint - + queue.description - + queue.description-hint
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html index 896adb0c46..ffbfb48cf3 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html @@ -34,14 +34,16 @@
+ [required]="true"> {{(direction === entitySearchDirection.FROM ? 'relation.to-entity' : 'relation.from-entity') | translate}}
-
+
{{(direction == directions.FROM ? 'relation.from-relations' : 'relation.to-relations') | translate}} - + relation.direction diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html index d4213b95e5..7543feb3ad 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html @@ -17,7 +17,7 @@ -->
{{ title }}
- + rule-node-config.save-time-series.strategy @for (strategy of processingStrategies; track strategy) { @@ -34,6 +34,7 @@ maxErrorText="{{ 'rule-node-config.save-time-series.deduplication-interval-min-max-range' | translate }}" [minTime]="1" [maxTime]="maxDeduplicateTime" + [appearance]="appearance" formControlName="deduplicationIntervalSecs"> } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts index 22cdec0c82..c569d3270f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts @@ -32,6 +32,7 @@ import { } from '@home/components/rule-node/action/timeseries-config.models'; import { isDefinedAndNotNull } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-advanced-processing-setting-row', @@ -52,6 +53,9 @@ export class AdvancedProcessingSettingRowComponent implements ControlValueAccess @Input() title: string; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + processingSettingRowForm = this.fb.group({ type: [defaultAdvancedProcessingConfig.type], deduplicationIntervalSecs: [{value: 60, disabled: true}] diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html index 5b403ff611..0136d08250 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html @@ -24,30 +24,35 @@ @if (timeseries) { } @if (attributes) { } @if (latest) { } @if (webSockets) { } @if (calculatedFields) { } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html index 2ac22c6284..64a23c30e6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html @@ -17,7 +17,7 @@ -->
- + rule-node-config.customer-name-pattern @if (assignCustomerConfigForm.get('customerNamePattern').hasError('required') || diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html index 50444e3a87..51ac7c34b6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html @@ -28,7 +28,7 @@
@if(!attributesConfigForm.get('processingSettings.isAdvanced').value) { - + rule-node-config.save-attribute.strategy @for (strategy of processingStrategies; track strategy) { @@ -39,6 +39,7 @@ @if(attributesConfigForm.get('processingSettings.type').value === ProcessingType.DEDUPLICATE) {
- + {{ 'rule-node-config.attributes-scope' | translate }} @@ -74,7 +75,7 @@ } - + {{ 'rule-node-config.attributes-scope-value' | translate }}
- + rule-node-config.alarm-type @if (clearAlarmConfigForm.get('alarmType').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html index eba427c385..6b4ec383a1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html @@ -77,7 +77,7 @@ } @if (createAlarmConfigForm.get('useMessageAlarmData').value === false) {
- + rule-node-config.alarm-type @if (createAlarmConfigForm.get('alarmType').hasError('required')) { @@ -91,7 +91,7 @@ {{ 'rule-node-config.use-alarm-severity-pattern' | translate }} @if (!createAlarmConfigForm.get('dynamicSeverity').value) { - + rule-node-config.alarm-severity @for (severity of alarmSeverities; track severity) { @@ -108,7 +108,7 @@ } @if (createAlarmConfigForm.get('dynamicSeverity').value) { - + rule-node-config.alarm-severity-pattern @if (createAlarmConfigForm.get('severity').hasError('required')) { @@ -124,7 +124,7 @@ @if (createAlarmConfigForm.get('propagate').value === true) {
- + rule-node-config.relation-types-list @for (key of createAlarmConfigForm.get('relationTypes').value; track key) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html index 817ff84b53..a50b62b26d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html @@ -19,7 +19,7 @@
rule-node-config.relation-parameters
- + relation.direction @for (type of directionTypes; track type) { @@ -30,6 +30,7 @@ @@ -40,6 +41,7 @@
rule-node-config.target-entity
{{ entityTypeNamePatternTranslation.get(createRelationConfigForm.get('entityType').value) | translate }} @@ -59,6 +62,7 @@ @if (createRelationConfigForm.get('entityType').value === entityType.DEVICE || createRelationConfigForm.get('entityType').value === entityType.ASSET) { rule-node-config.profile-name diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html index cb0b89d1ed..a260f16b96 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html @@ -20,7 +20,7 @@
- + {{ 'rule-node-config.attributes-scope' | translate }} @@ -31,7 +31,7 @@ } - + {{ 'rule-node-config.attributes-scope-value' | translate }}
- + {{ 'rule-node-config.attributes-keys' | translate }} @for (key of deleteAttributesConfigForm.get('keys').value; track key) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html index 7899e4773d..5c1517804d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html @@ -19,7 +19,7 @@
rule-node-config.relation-parameters
- + relation.direction @for (type of directionTypes; track type) { @@ -30,6 +30,7 @@ @@ -49,11 +50,13 @@ class="flex-1" showLabel required + appearance="outline" [allowedEntityTypes]="allowedEntityTypes" formControlName="entityType"> @if (deleteRelationConfigForm.get('entityType').value && deleteRelationConfigForm.get('entityType').value !== entityType.TENANT) { {{ entityTypeNamePatternTranslation.get(deleteRelationConfigForm.get('entityType').value) | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html index cb3d6d2b7e..bdb412f385 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html @@ -16,7 +16,7 @@ -->
- + {{ 'rule-node-config.select-device-connectivity-event' | translate }} @for (eventOption of eventOptions; track eventOption) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html index cea850d0be..d0d91e0bb1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html @@ -19,7 +19,7 @@
rule-node-config.generation-parameters
- + rule-node-config.message-count @if (generatorConfigForm.get('msgCount').hasError('required')) { @@ -33,7 +33,7 @@ } - + rule-node-config.generation-frequency-seconds @if (generatorConfigForm.get('periodInSeconds').hasError('required')) { @@ -53,6 +53,7 @@
rule-node-config.originator
rule-node-config.coordinate-field-names
- + {{ 'rule-node-config.latitude-field-name' | translate }} @if (geoActionConfigForm.get('latitudeKeyName').hasError('required')) { @@ -29,7 +29,7 @@ } - + {{ 'rule-node-config.longitude-field-name' | translate }} @if (geoActionConfigForm.get('longitudeKeyName').hasError('required')) { @@ -45,7 +45,7 @@
rule-node-config.geofence-configuration
- + {{ 'rule-node-config.perimeter-type' | translate }} @for (type of perimeterTypes; track type) { @@ -70,7 +70,7 @@
@if (geoActionConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { - + {{ 'rule-node-config.perimeter-key-name' | translate }} @if (geoActionConfigForm.get('perimeterKeyName').hasError('required')) { @@ -83,8 +83,7 @@ } @if (geoActionConfigForm.get('perimeterType').value === perimeterType.CIRCLE && !geoActionConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { -
+
{{ 'rule-node-config.circle-center-latitude' | translate }} @@ -95,7 +94,7 @@ } - + {{ 'rule-node-config.circle-center-longitude' | translate }} @if (geoActionConfigForm.get('centerLongitude').hasError('required')) { @@ -106,7 +105,7 @@
- + {{ 'rule-node-config.range' | translate }} @if (geoActionConfigForm.get('range').hasError('required')) { @@ -115,7 +114,7 @@ } - + {{ 'rule-node-config.range-units' | translate }} @for (type of rangeUnits; track type) { @@ -136,7 +135,7 @@ @if (geoActionConfigForm.get('perimeterType').value === perimeterType.POLYGON && !geoActionConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) {
- + rule-node-config.polygon-definition
- + rule-node-config.min-inside-duration @if (geoActionConfigForm.get('minInsideDuration').hasError('required')) { @@ -193,7 +192,7 @@ } - + rule-node-config.min-inside-duration-time-unit @for (timeUnit of timeUnits; track timeUnit) { @@ -205,7 +204,7 @@
- + rule-node-config.min-outside-duration @if (geoActionConfigForm.get('minOutsideDuration').hasError('required')) { @@ -224,7 +223,7 @@ } - + rule-node-config.min-outside-duration-time-unit @for (timeUnit of timeUnits; track timeUnit) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html index bd5fad7679..302b3828ba 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html @@ -19,6 +19,7 @@
@@ -31,7 +32,7 @@
{{'rule-node-config.custom-expression-field-input' | translate }} * - +
rule-node-config.result-title
- + rule-node-config.type-field-input @@ -75,7 +76,7 @@
@if (mathFunctionConfigForm.get('result').get('type').value === ArgumentTypeResult.ATTRIBUTE) { - + rule-node-config.attribute-scope-field-input @for (scope of attributeScopeResult; track scope) { @@ -86,7 +87,7 @@ } - + rule-node-config.key-field-input
- + rule-node-config.number-floating-point-field-input
@if ([ArgumentTypeResult.ATTRIBUTE, ArgumentTypeResult.TIME_SERIES].includes(mathFunctionConfigForm.get('result').get('type').value)) { -
+
{{'rule-node-config.add-to-message-field-input' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/msg-count-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/msg-count-config.component.html index 31010602c3..305ce7615b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/msg-count-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/msg-count-config.component.html @@ -16,7 +16,7 @@ -->
- + rule-node-config.interval-seconds @if (msgCountConfigForm.get('interval').hasError('required')) { @@ -30,7 +30,7 @@ } - + rule-node-config.output-timeseries-key-prefix @if (msgCountConfigForm.get('telemetryPrefix').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/msg-delay-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/msg-delay-config.component.html index a7dc7b7b1b..1b23da7dea 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/msg-delay-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/msg-delay-config.component.html @@ -22,6 +22,7 @@
rule-node-config.use-metadata-period-in-seconds-patterns-hint
@if (msgDelayConfigForm.get('useMetadataPeriodInSecondsPatterns').value !== true) { rule-node-config.period-seconds @@ -37,7 +38,7 @@ } } @else { - + rule-node-config.period-in-seconds-pattern @if (msgDelayConfigForm.get('periodInSecondsPattern').hasError('required')) { @@ -48,7 +49,7 @@ rule-node-config.general-pattern-hint } - + rule-node-config.max-pending-messages @if (msgDelayConfigForm.get('maxPendingMsgs').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.html index 5a39dae562..f4e312fe00 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.html @@ -20,7 +20,7 @@
- + {{ 'rule-node-config.attributes-scope' | translate }} @@ -31,7 +31,7 @@ } - + {{ 'rule-node-config.attributes-scope-value' | translate }}
@if (unassignCustomerConfigForm.get('unassignFromCustomer').value) { - + rule-node-config.customer-name-pattern @if (unassignCustomerConfigForm.get('customerNamePattern').hasError('required') || diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html index ee56981b6a..633c169cf1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html @@ -45,8 +45,8 @@ {{argumentControl.get('name').value}}. } -
- +
+ rule-node-config.argument-source-field-input @@ -70,8 +70,7 @@
@if (argumentControl.get('type').value && argumentControl.get('type').value !== ArgumentType.CONSTANT) { - + rule-node-config.argument-key-field-input } @if (argumentControl.get('type').value === ArgumentType.CONSTANT) { - + rule-node-config.constant-value-field-input } @if (argumentControl.get('type').value && argumentControl.get('type').value !== ArgumentType.CONSTANT) { - + rule-node-config.default-value-field-input }
@if (argumentControl.get('type').value === ArgumentType.ATTRIBUTE) { - + rule-node-config.attribute-scope-field-input @for (scope of attributeScope; track scope) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html index dd5579b230..664bc64aad 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html @@ -24,7 +24,7 @@ - + rule-node-config.credentials-type @for (credentialsType of allCredentialsTypes; track credentialsType) { @@ -44,7 +44,7 @@
@switch (credentialsConfigFormGroup.get('type').value) { @case("basic") { - + rule-node-config.username @if (credentialsConfigFormGroup.get('username').hasError('required')) { @@ -53,7 +53,7 @@ } - + rule-node-config.password diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html index 380668f9fd..6a25b5e6cb 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html @@ -17,7 +17,7 @@ -->
- + relation.direction @for (type of directionTypes; track type) { @@ -27,7 +27,7 @@ } - + rule-node-config.max-relation-level } - + @if (keyValControl.get('key').hasError('required')) { @@ -40,7 +40,7 @@ } - + @if (keyValControl.get('value').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.html index d7dc9b6bb3..d706102304 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.html @@ -47,8 +47,7 @@
@for (keyValControl of keyValsFormArray().controls; track keyValControl) { -
+
@@ -56,7 +55,7 @@
-
+ } @empty { + @if (emptyText) { +
{{ emptyText }}
+ } }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts index e98f145724..ac8b62487e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts @@ -80,6 +80,8 @@ export class KvMapConfigComponent implements ControlValueAccessor, OnInit, Valid @Input() hintText: string; + @Input() emptyText: string; + @Input() popupHelpLink: string; @Input() @@ -171,7 +173,7 @@ export class KvMapConfigComponent implements ControlValueAccessor, OnInit, Valid }; writeValue(keyValMap: { [key: string]: string }): void { - const keyValuesData = Object.keys(keyValMap).map(key => ({key, value: keyValMap[key]})); + const keyValuesData = Object.keys(keyValMap ?? {}).map(key => ({key, value: keyValMap[key]})); if (this.keyValsFormArray().length === keyValuesData.length) { this.keyValsFormArray().patchValue(keyValuesData, {emitEvent: false}); } else { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html index 8dfbbaf1d6..5ae8457030 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + rule-node-config.functions-field-input - + @if (label) { {{ label }} } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html index 138d45700b..035a739918 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html @@ -16,7 +16,7 @@ -->
- + {{'rule-node-config.output-message-type' | translate}} @for (msgType of messageTypes; track msgType) { @@ -26,7 +26,7 @@ } - + {{'rule-node-config.message-type-value' | translate}} @if (messageTypeFormGroup.get('messageType').value) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts index 5cb9922fcb..9395f59d6a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts @@ -24,7 +24,7 @@ import { Validator, Validators } from '@angular/forms'; -import { SubscriptSizing } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { coerceBoolean } from '@shared/public-api'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -61,6 +61,9 @@ export class OutputMessageTypeAutocompleteComponent implements ControlValueAcces @coerceBoolean() disabled: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Input() @coerceBoolean() set required(value) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html index 5f8754c723..01ec5c5e9a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html @@ -19,7 +19,7 @@
rule-node-config.relations-query
- + relation.direction @for (type of directionTypes; track type) { @@ -29,7 +29,7 @@ } - + rule-node-config.max-relation-level diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html index dcc5fee728..6011ea79c5 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html @@ -17,7 +17,7 @@ -->
- + {{ 'rule-node-config.input-value-key' | translate }} @if (calculateDeltaConfigForm.get('inputValueKey').hasError('required') || @@ -27,7 +27,7 @@ } - + {{ 'rule-node-config.output-value-key' | translate }} @if (calculateDeltaConfigForm.get('outputValueKey').hasError('required') || @@ -38,7 +38,7 @@ }
- + {{ 'rule-node-config.number-of-digits-after-floating-point' | translate }} @if (calculateDeltaConfigForm.get('round').hasError('min')) { @@ -80,7 +80,7 @@
@if (calculateDeltaConfigForm.get('addPeriodBetweenMsgs').value) { - + {{ 'rule-node-config.period-value-key' | translate }} @if (calculateDeltaConfigForm.get('periodValueKey').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html index ff7939cab4..50af390ff8 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html @@ -18,6 +18,7 @@
@@ -40,10 +41,9 @@
@if (getTelemetryFromDatabaseConfigForm.get('useMetadataIntervalPatterns').value === false) { -
+
- + {{ 'rule-node-config.interval-start' | translate }} @if (getTelemetryFromDatabaseConfigForm.get('interval.startInterval').hasError('required')) { @@ -62,7 +62,7 @@ } - + {{ 'rule-node-config.time-unit' | translate }} @for (timeUnit of timeUnits; track timeUnit) { @@ -74,7 +74,7 @@
- + {{ 'rule-node-config.interval-end' | translate }} @if (getTelemetryFromDatabaseConfigForm.get('interval.endInterval').hasError('required')) { @@ -93,7 +93,7 @@ } - + {{ 'rule-node-config.time-unit' | translate }} @for (timeUnit of timeUnits; track timeUnit) { @@ -123,7 +123,7 @@
} @else {
- + {{ 'rule-node-config.start-interval' | translate }} @if (getTelemetryFromDatabaseConfigForm.get('startIntervalPattern').hasError('required') || @@ -133,7 +133,7 @@ } - + {{ 'rule-node-config.end-interval' | translate }} @if (getTelemetryFromDatabaseConfigForm.get('endIntervalPattern').hasError('required') || @@ -167,7 +167,7 @@
@if (getTelemetryFromDatabaseConfigForm.get('fetchMode').value === fetchMode.ALL) {
- {{ 'aggregation.function' | translate }} @@ -180,7 +180,7 @@ @if (getTelemetryFromDatabaseConfigForm.get('aggregation').value === aggregationTypes.NONE) {
- + {{ "rule-node-config.order-by-timestamp" | translate }} @for (order of samplingOrders; track order) { @@ -190,7 +190,7 @@ } - + {{ "rule-node-config.limit" | translate }} {{ "rule-node-config.limit-hint" | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index 7167b68132..9b0a51d7ec 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -82,6 +82,7 @@
- + rule-node-config.topic @if (azureIotHubConfigForm.get('topicPattern').hasError('required')) { @@ -26,7 +26,7 @@ } rule-node-config.general-pattern-hint - + rule-node-config.hostname @if (azureIotHubConfigForm.get('host').hasError('required')) { @@ -35,7 +35,7 @@ } - + rule-node-config.device-id @if (azureIotHubConfigForm.get('clientId').hasError('required')) { @@ -44,7 +44,7 @@ } - @@ -56,7 +56,7 @@
- + rule-node-config.credentials-type @for (credentialsType of allAzureIotHubCredentialsTypes; track credentialsType) { @@ -75,7 +75,7 @@ @switch (azureIotHubConfigForm.get('credentials.type').value) { @case ('anonymous') {} @case ('sas') { - + rule-node-config.sas-key @@ -124,7 +124,7 @@ noFileText="rule-node-config.no-file" dropLabel="{{'rule-node-config.drop-file' | translate}}"> - + rule-node-config.private-key-password diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html index ffbc99fe57..cc02018710 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html @@ -16,7 +16,7 @@ -->
- + rule-node-config.topic-pattern @if (kafkaConfigForm.get('topicPattern').hasError('required')) { @@ -26,13 +26,13 @@ } rule-node-config.general-pattern-hint - + rule-node-config.key-pattern rule-node-config.general-pattern-hint
rule-node-config.key-pattern-hint
- + rule-node-config.bootstrap-servers @if (kafkaConfigForm.get('bootstrapServers').hasError('required')) { @@ -41,7 +41,7 @@ } - + rule-node-config.retries @if (kafkaConfigForm.get('retries').hasError('min')) { @@ -50,7 +50,7 @@ } - + rule-node-config.batch-size-bytes @if (kafkaConfigForm.get('batchSize').hasError('min')) { @@ -59,7 +59,7 @@ } - + rule-node-config.linger-ms @if (kafkaConfigForm.get('linger').hasError('min')) { @@ -68,7 +68,7 @@ } - + rule-node-config.buffer-memory-bytes @if (kafkaConfigForm.get('bufferMemory').hasError('min')) { @@ -77,7 +77,7 @@ } - + rule-node-config.acks @for (ackValue of ackValues; track ackValue) { @@ -88,20 +88,21 @@ - - + [keyText]="'rule-node-config.key' | translate" + [keyRequiredText]="'rule-node-config.key-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.other-properties-empty' | translate"> + {{ 'rule-node-config.add-metadata-key-values-as-kafka-headers' | translate }}
rule-node-config.add-metadata-key-values-as-kafka-headers-hint
@if (kafkaConfigForm.get('addMetadataKeyValuesAsKafkaHeaders').value) { - + rule-node-config.charset-encoding @for (charset of ToByteStandartCharsetTypesValues; track charset) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html index fba6b87d72..4e80aad77b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html @@ -24,7 +24,7 @@ popupHelpLink="rulenode/node-templatization-doc">
- + {{'rule-node-config.function-name' | translate}} @if (lambdaConfigForm.get('functionName').hasError('required')) { @@ -33,7 +33,7 @@ } - + {{'rule-node-config.qualifier' | translate}} rule-node-config.qualifier-hint @@ -47,7 +47,7 @@ rule-node-config.aws-credentials
- + rule-node-config.aws-access-key-id @if (lambdaConfigForm.get('accessKey').hasError('required')) { @@ -56,7 +56,7 @@ } - + rule-node-config.aws-secret-access-key @if (lambdaConfigForm.get('secretKey').hasError('required')) { @@ -65,7 +65,7 @@ } - + rule-node-config.aws-region @if (lambdaConfigForm.get('region').hasError('required')) { @@ -84,7 +84,7 @@
- + rule-node-config.connection-timeout @if (lambdaConfigForm.get('connectionTimeout').hasError('required')) { @@ -101,7 +101,7 @@ color="primary" matTooltip="{{ 'rule-node-config.connection-timeout-hint' | translate }}">help - + rule-node-config.request-timeout @if (lambdaConfigForm.get('requestTimeout').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/mqtt-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/mqtt-config.component.html index e756cdb961..127e67db0d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/mqtt-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/mqtt-config.component.html @@ -16,7 +16,7 @@ -->
- + rule-node-config.topic-pattern @if (mqttConfigForm.get('topicPattern').hasError('required')) { @@ -27,7 +27,7 @@ rule-node-config.general-pattern-hint
- + rule-node-config.host @if (mqttConfigForm.get('host').hasError('required')) { @@ -36,7 +36,7 @@ } - + rule-node-config.port @if (mqttConfigForm.get('port').hasError('required')) { @@ -55,7 +55,7 @@ } - + rule-node-config.connect-timeout @if (mqttConfigForm.get('connectTimeoutSec').hasError('required')) { @@ -75,7 +75,7 @@ }
- + rule-node-config.client-id {{'rule-node-config.client-id-hint' | translate}} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html index ce937fe7d7..8101d2b2a6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html @@ -19,12 +19,14 @@
- + rule-node-config.gcp-project-id @if (pubSubConfigForm.get('projectId').hasError('required')) { @@ -25,7 +25,7 @@ } - + rule-node-config.pubsub-topic-name @if (pubSubConfigForm.get('topicName').hasError('required')) { @@ -46,12 +46,13 @@
- - + [keyText]="'rule-node-config.name' | translate" + [keyRequiredText]="'rule-node-config.name-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.message-attributes-empty' | translate"> +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html index 19f06ef77d..71b53b53f6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html @@ -16,15 +16,15 @@ -->
- + rule-node-config.exchange-name-pattern - + rule-node-config.routing-key-pattern - + rule-node-config.message-properties @for (property of messageProperties; track property) { @@ -35,7 +35,7 @@
- + rule-node-config.host @if (rabbitMqConfigForm.get('host').hasError('required')) { @@ -44,7 +44,7 @@ } - + rule-node-config.port @if (rabbitMqConfigForm.get('port').hasError('required')) { @@ -64,15 +64,15 @@ }
- + rule-node-config.virtual-host - + rule-node-config.username - + rule-node-config.password @@ -80,7 +80,7 @@ {{ 'rule-node-config.automatic-recovery' | translate }} - + rule-node-config.connection-timeout-ms @if (rabbitMqConfigForm.get('connectionTimeout').hasError('min')) { @@ -89,7 +89,7 @@ } - + rule-node-config.handshake-timeout-ms @if (rabbitMqConfigForm.get('handshakeTimeout').hasError('min')) { @@ -99,12 +99,13 @@ } - - + [keyText]="'rule-node-config.key' | translate" + [keyRequiredText]="'rule-node-config.key-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.client-properties-empty' | translate"> +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html index 3b2a6a1cb6..f5ec1ba156 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html @@ -18,7 +18,7 @@
rule-node-config.request-url
- + rule-node-config.url @if (restApiCallConfigForm.get('restEndpointUrlPattern').hasError('required')) { @@ -45,7 +45,7 @@
- + rule-node-config.request-method @for (requestType of httpRequestTypes; track requestType) { @@ -61,15 +61,16 @@ {{ 'rule-node-config.headers' | translate }} -
+ - + [keyText]="'rule-node-config.header' | translate" + [keyRequiredText]="'rule-node-config.header-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.headers-empty' | translate"> +
@@ -92,7 +93,7 @@ @if (!restApiCallConfigForm.get('useSystemProxyProperties').value) {
- + rule-node-config.proxy-host @if (restApiCallConfigForm.get('proxyHost').hasError('required')) { @@ -101,7 +102,7 @@ } - + rule-node-config.proxy-port @if (restApiCallConfigForm.get('proxyPort').hasError('required')) { @@ -116,11 +117,11 @@ }
- + rule-node-config.proxy-user - + rule-node-config.proxy-password @@ -137,12 +138,23 @@
-
+
{{ 'rule-node-config.ignore-request-body' | translate }} + @if(!restApiCallConfigForm.get('ignoreRequestBody').value) { +
+ + rule-node-config.request-body-template + + + }
- + rule-node-config.read-timeout rule-node-config.read-timeout-hint @@ -152,7 +164,7 @@ } - + rule-node-config.max-parallel-requests-count rule-node-config.max-parallel-requests-count-hint @@ -162,7 +174,7 @@ } - + rule-node-config.max-response-size rule-node-config.max-response-size-hint diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss index a5ed420c88..a4ad3fe4f8 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss @@ -26,4 +26,7 @@ } } } + textarea.tb-enable-vertical-resize { + resize: vertical; + } } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts index c9694ccd94..32ea3d57d9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts @@ -51,6 +51,7 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]], parseToPlainText: [configuration ? configuration.parseToPlainText : false, []], ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []], + requestBodyTemplate: [configuration ? configuration.requestBodyTemplate : null, []], enableProxy: [configuration ? configuration.enableProxy : false, []], useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []], proxyHost: [configuration ? configuration.proxyHost : null, []], diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html index 295de6912a..9f5f9a590f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html @@ -21,7 +21,7 @@ @if (sendEmailConfigForm.get('useSystemSmtpSettings').value === false) {
- + rule-node-config.smtp-protocol @for (smtpProtocol of smtpProtocols; track smtpProtocol) { @@ -32,7 +32,7 @@
- + rule-node-config.smtp-host @if (sendEmailConfigForm.get('smtpHost').hasError('required')) { @@ -41,7 +41,7 @@ } - + rule-node-config.smtp-port @if (sendEmailConfigForm.get('smtpPort').hasError('required')) { @@ -61,7 +61,7 @@ }
- + rule-node-config.timeout-msec @if (sendEmailConfigForm.get('timeout').hasError('required')) { @@ -79,7 +79,7 @@ {{ 'rule-node-config.enable-tls' | translate }} @if (sendEmailConfigForm.get('enableTls').value === true) { - + rule-node-config.tls-version @for (tlsVersion of tlsVersions; track tlsVersion) { @@ -96,7 +96,7 @@ @if (sendEmailConfigForm.get('enableProxy').value) {
- + rule-node-config.proxy-host @if (sendEmailConfigForm.get('proxyHost').hasError('required')) { @@ -105,7 +105,7 @@ } - + rule-node-config.proxy-port @if (sendEmailConfigForm.get('proxyPort').hasError('required')) { @@ -120,21 +120,21 @@ }
- + rule-node-config.proxy-user - + rule-node-config.proxy-password
} - + rule-node-config.username - + rule-node-config.password diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html index ff9a77a3f5..d9136b3cb6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html @@ -16,7 +16,7 @@ -->
- + rule-node-config.numbers-to-template @if (sendSmsConfigForm.get('numbersToTemplate').hasError('required')) { @@ -26,9 +26,9 @@ } - + rule-node-config.sms-message-template - + @if (sendSmsConfigForm.get('smsMessageTemplate').hasError('required')) { {{ 'rule-node-config.sms-message-template-required' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html index 9ef122ad9e..31f91ba86a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html @@ -16,7 +16,7 @@ -->
- + rule-node-config.message-template @if (slackConfigForm.get('messageTemplate').hasError('required')) { @@ -30,7 +30,7 @@ {{ 'rule-node-config.use-system-slack-settings' | translate }} @if (!slackConfigForm.get('useSystemSettings').value) { - + rule-node-config.slack-api-token @if (slackConfigForm.get('botToken').hasError('required')) { @@ -49,6 +49,7 @@ }
- + rule-node-config.topic-arn-pattern @if (snsConfigForm.get('topicArnPattern').hasError('required')) { @@ -26,7 +26,7 @@ } rule-node-config.general-pattern-hint - + rule-node-config.aws-access-key-id @if (snsConfigForm.get('accessKeyId').hasError('required')) { @@ -35,7 +35,7 @@ } - + rule-node-config.aws-secret-access-key @if (snsConfigForm.get('secretAccessKey').hasError('required')) { @@ -44,7 +44,7 @@ } - + rule-node-config.aws-region @if (snsConfigForm.get('region').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html index 0e07578f19..e803d37921 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html @@ -16,7 +16,7 @@ -->
- + rule-node-config.queue-type @for (type of sqsQueueTypes; track type) { @@ -26,7 +26,7 @@ } - + rule-node-config.queue-url-pattern @if (sqsConfigForm.get('queueUrlPattern').hasError('required')) { @@ -37,7 +37,7 @@ rule-node-config.general-pattern-hint @if (sqsConfigForm.get('queueType').value === sqsQueueType.STANDARD) { - + rule-node-config.delay-seconds @if (sqsConfigForm.get('delaySeconds').hasError('min')) { @@ -54,15 +54,16 @@ }
- - - + [keyText]="'rule-node-config.name' | translate" + [keyRequiredText]="'rule-node-config.name-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.message-attributes-empty' | translate"> + + rule-node-config.aws-access-key-id @if (sqsConfigForm.get('accessKeyId').hasError('required')) { @@ -71,7 +72,7 @@ } - + rule-node-config.aws-secret-access-key @if (sqsConfigForm.get('secretAccessKey').hasError('required')) { @@ -80,7 +81,7 @@ } - + rule-node-config.aws-region @if (sqsConfigForm.get('region').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html index c4cd8f18ed..ae5a957bf6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html @@ -23,6 +23,7 @@
@@ -30,6 +31,7 @@ matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.field-name' | translate } }}">help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html index 3e2773037d..6aee9bf101 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html @@ -18,7 +18,7 @@
rule-node-config.relation-search-parameters
- + {{ 'relation.direction' | translate }} @for (direction of entitySearchDirection; track direction) { @@ -29,6 +29,7 @@ @@ -41,6 +42,7 @@ @if (checkRelationConfigForm.get('checkForSingleEntity').value) {
@if (checkRelationConfigForm.get('entityType').value) { rule-node-config.coordinate-field-names
- + {{ 'rule-node-config.latitude-field-name' | translate }} @if (geoFilterConfigForm.get('latitudeKeyName').hasError('required')) { @@ -29,7 +29,7 @@ } - + {{ 'rule-node-config.longitude-field-name' | translate }} @if (geoFilterConfigForm.get('longitudeKeyName').hasError('required')) { @@ -45,7 +45,7 @@
rule-node-config.geofence-configuration
- + {{ 'rule-node-config.perimeter-type' | translate }} @for (type of perimeterTypes; track type) { @@ -70,7 +70,7 @@
@if (geoFilterConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { - + {{ 'rule-node-config.perimeter-key-name' | translate }} @if (geoFilterConfigForm.get('perimeterKeyName').hasError('required')) { @@ -83,10 +83,9 @@ } @if (geoFilterConfigForm.get('perimeterType').value === perimeterType.CIRCLE && !geoFilterConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { -
+
- + {{ 'rule-node-config.circle-center-latitude' | translate }} @if (geoFilterConfigForm.get('centerLatitude').hasError('required')) { @@ -95,7 +94,7 @@ } - + {{ 'rule-node-config.circle-center-longitude' | translate }} @if (geoFilterConfigForm.get('centerLongitude').hasError('required')) { @@ -106,7 +105,7 @@
- + {{ 'rule-node-config.range' | translate }} @if (geoFilterConfigForm.get('range').hasError('required')) { @@ -115,7 +114,7 @@ } - + {{ 'rule-node-config.range-units' | translate }} @for (type of rangeUnits; track type) { @@ -135,8 +134,7 @@ } @if (geoFilterConfigForm.get('perimeterType').value === perimeterType.POLYGON && !geoFilterConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { - + {{ 'rule-node-config.polygon-definition' | translate }} {{ 'rule-node-config.polygon-definition-hint' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html index a0d90bab4a..b94519d914 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html @@ -17,6 +17,7 @@ -->
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html index 22d58fb9fc..efcea2eb7b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html @@ -16,7 +16,7 @@ -->
- + rule-node-config.new-originator @@ -40,8 +40,7 @@ @if (changeOriginatorConfigForm.get('originatorSource').value === originatorSource.ENTITY) { -
+
@@ -49,11 +48,12 @@ - + rule-node-config.entity-name-pattern @if (changeOriginatorConfigForm.get('entityNamePattern').hasError('required') || diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html index 0fc80e6197..a899a344b3 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html @@ -23,6 +23,7 @@
- + {{'rule-node-config.interval' | translate}} @if (deduplicationConfigForm.get('interval').hasError('required')) { @@ -44,26 +44,24 @@ } @if (deduplicationConfigForm.get('strategy').value === 'ALL') { - + } @if (deduplicationConfigForm.get('strategy').value === 'FIRST') { + textAlign="'center'"> } @if (deduplicationConfigForm.get('strategy').value === 'LAST') { + textAlign="'center'"> } @if (deduplicationConfigForm.get('strategy').value === deduplicationStrategie.ALL) {
@@ -75,7 +73,7 @@ rule-node-config.advanced-settings
- + {{'rule-node-config.max-pending-msgs' | translate}} @if (deduplicationConfigForm.get('maxPendingMsgs').hasError('required')) { @@ -97,7 +95,7 @@ color="primary" matTooltip="{{ 'rule-node-config.max-pending-msgs-hint' | translate }}">help - + {{'rule-node-config.max-retries' | translate}} @if (deduplicationConfigForm.get('maxRetries').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html index c5d94739d6..4da3fdf45c 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html @@ -22,6 +22,7 @@
- + {{ 'rule-node-config.json-path-expression' | translate }} {{ 'rule-node-config.json-path-expression-hint' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/to-email-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/to-email-config.component.html index f2d8e253ec..118faf6657 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/to-email-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/to-email-config.component.html @@ -19,7 +19,7 @@
rule-node-config.email-sender
- + rule-node-config.from-template @@ -52,7 +52,7 @@
- + rule-node-config.to-template - + rule-node-config.bcc-template +
diff --git a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html index 599b217e61..4aed4a41b8 100644 --- a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html @@ -37,6 +37,7 @@ diff --git a/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html index 1e105ff86a..efa6ef3c1c 100644 --- a/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html @@ -36,6 +36,7 @@
{{ assignToCustomerText | translate }}
diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 5685487b3f..a9d57a0d41 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -38,6 +38,7 @@ import { Timewindow, WidgetTimewindow } from '@shared/models/time/time.models'; import { IAliasController, IStateController, + IWidgetHttpUtils, IWidgetSubscription, IWidgetUtils, RpcApi, @@ -58,6 +59,13 @@ import { ViewContainerRef } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { + createDefaultHttpOptions, + defaultHttpOptions, + defaultHttpOptionsFromConfig, + defaultHttpOptionsFromParams, + defaultHttpUploadOptions +} from '@core/http/http-utils'; import { RafService } from '@core/services/raf.service'; import { WidgetTypeId } from '@shared/models/id/widget-type-id'; import { TenantId } from '@shared/models/id/tenant-id'; @@ -68,7 +76,8 @@ import { formatValue, getEntityDetailsPageURL, hasDatasourceLabelsVariables, - isDefined + isDefined, + isDefinedAndNotNull } from '@core/utils'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -294,6 +303,14 @@ export class WidgetContext { getEntityDetailsPageURL }; + httpUtils: IWidgetHttpUtils = { + defaultHttpOptions, + defaultHttpOptionsFromConfig, + defaultHttpOptionsFromParams, + defaultHttpUploadOptions, + createDefaultHttpOptions + }; + $widgetElement: JQuery; $container: JQuery; $containerParent: JQuery; @@ -555,7 +572,9 @@ export class LabelVariablePattern { const entityInfo = this.ctx.defaultSubscription.getFirstEntityInfo(); label = createLabelFromSubscriptionEntityInfo(entityInfo, label); } else { - const datasource = this.ctx.defaultSubscription?.firstDatasource ?? (this.ctx as any).mapInstance?.getData()[0]; + const datasource = isDefinedAndNotNull(this.ctx.defaultSubscription) + ? this.ctx.defaultSubscription.firstDatasource ?? undefined + : (this.ctx as any).mapInstance?.getData()[0]; label = createLabelFromDatasource(datasource, label); } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html index e6dc6b74b9..0142c83a85 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html @@ -33,7 +33,7 @@
- + admin.base-url @if (generalSettings.get('baseUrl').hasError('required')) { @@ -104,11 +104,11 @@ {{ 'admin.device-connectivity.' + protocol | translate }}
- + admin.device-connectivity.host - + admin.device-connectivity.port @if (deviceConnectivitySettingsForm.get(protocol + '.port').hasError('pattern')) { diff --git a/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html index a653845ca3..25c9adfea0 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html @@ -36,6 +36,7 @@
- + admin.mail-from @if (mailSettings.get('mailFrom').hasError('required')) { @@ -44,7 +44,7 @@ } - + admin.oauth2.smtp-provider @for (provider of templateProvider; track provider) { @@ -62,7 +62,7 @@ - + admin.smtp-protocol @for (protocol of smtpProtocols; track protocol) { @@ -73,7 +73,7 @@
- + admin.smtp-host @if (mailSettings.get('smtpHost').hasError('required')) { @@ -82,7 +82,7 @@ } - + admin.smtp-port {{smtpPortInput.value?.length || 0}}/5 @@ -98,7 +98,7 @@ }
- + admin.timeout-msec {{timeoutInput.value?.length || 0}}/6 @@ -117,7 +117,7 @@ {{ 'admin.enable-tls' | translate }} @if (mailSettings.get('enableTls').value) { - + admin.tls-version @for (tlsVersion of tlsVersions; track tlsVersion) { @@ -134,7 +134,7 @@ @if (mailSettings.get('enableProxy').value) {
- + admin.proxy-host @if (mailSettings.get('proxyHost').hasError('required')) { @@ -143,7 +143,7 @@ } - + admin.proxy-port @if (mailSettings.get('proxyPort').hasError('required')) { @@ -161,11 +161,11 @@
- + admin.proxy-user - + admin.proxy-password @@ -178,7 +178,7 @@
admin.oauth2.authentication - + common.username @@ -202,7 +202,7 @@ } @if (mailSettings.get('changePassword').value || !showChangePassword) { - + common.password @@ -214,7 +214,7 @@ @if (mailSettings.get('enableOauth2').value) {
- + admin.oauth2.client-id @if (mailSettings.get('clientId').hasError('required')) { @@ -228,7 +228,7 @@ } - + admin.oauth2.client-secret @if (mailSettings.get('clientSecret').hasError('required')) { @@ -244,7 +244,7 @@
@if (mailSettings.get('providerId').value === mailServerOauth2Provider.OFFICE_365) { - + admin.oauth2.microsoft-tenant-id @if (mailSettings.get('providerTenantId').hasError('required')) { @@ -262,7 +262,7 @@
- + admin.oauth2.authorization-uri
@@ -313,7 +314,7 @@
- + admin.oauth2.protocol @for (protocol of protocols; track protocol) { @@ -323,7 +324,7 @@ } - + admin.domain-name @if (domainForm.get('name').hasError('pattern')) { @@ -345,7 +346,7 @@ }
- + admin.oauth2.redirect-uri-template
- + admin.oauth2.title @if (entityForm.get('title').hasError('required')) { @@ -34,7 +34,7 @@
- + admin.oauth2.provider @for (provider of templateProvider; track provider) { @@ -45,7 +45,7 @@
- + admin.oauth2.allowed-platforms @for (platform of platformTypes; track platform) { @@ -57,7 +57,7 @@
- + admin.oauth2.client-id @if (entityForm.get('clientId').hasError('required')) { @@ -71,7 +71,7 @@ } - + admin.oauth2.client-secret @if (entityForm.get('clientSecret').hasError('required')) { @@ -102,10 +102,9 @@
@if (generalSettingsMode) { -
+
- + admin.oauth2.access-token-uri @if (entityForm.get('accessTokenUri').hasError('required')) { @@ -119,7 +118,7 @@ } - + admin.oauth2.authorization-uri @if (entityForm.get('authorizationUri').hasError('required')) { @@ -135,7 +134,7 @@
- + admin.oauth2.jwk-set-uri @if (entityForm.get('jwkSetUri').hasError('pattern')) { @@ -144,7 +143,7 @@ } - + admin.oauth2.user-info-uri @if (entityForm.get('userInfoUri').hasError('pattern')) { @@ -155,7 +154,7 @@
- + admin.oauth2.client-authentication-method @for (clientAuthenticationMethod of clientAuthenticationMethods; track clientAuthenticationMethod) { @@ -169,7 +168,7 @@
@if (isCustomProvider()) {
- + admin.oauth2.login-button-label @@ -203,6 +202,7 @@
@@ -212,7 +212,7 @@ @if (!generalSettingsMode) {
- + admin.oauth2.user-name-attribute-name @if (entityForm.get('userNameAttributeName').hasError('required')) { @@ -224,7 +224,7 @@
- + admin.oauth2.type @for (mapperType of mapperTypes; track mapperType) { @@ -237,23 +237,19 @@
@if (entityForm.get('mapperConfig.type').value !== mapperType.CUSTOM && entityForm.get('mapperConfig.basic')) { -
+
@if (entityForm.get('mapperConfig.type').value !== mapperType.GITHUB) { -
- +
+ admin.oauth2.email-attribute-key @if (entityForm.get('mapperConfig.basic.emailAttributeKey').hasError('required')) { - + {{ 'admin.oauth2.email-attribute-key-required' | translate }} } @if (entityForm.get('mapperConfig.basic.emailAttributeKey').hasError('maxlength')) { - + {{ 'admin.oauth2.email-attribute-key-max-length' | translate }} } @@ -261,7 +257,7 @@
}
- + admin.oauth2.first-name-attribute-key @if (entityForm.get('mapperConfig.basic.firstNameAttributeKey').hasError('maxlength')) { @@ -270,7 +266,7 @@ } - + admin.oauth2.last-name-attribute-key @if (entityForm.get('mapperConfig.basic.lastNameAttributeKey').hasError('maxlength')) { @@ -281,7 +277,7 @@
- + admin.oauth2.tenant-name-strategy @for (tenantNameStrategy of tenantNameStrategies; track tenantNameStrategy) { @@ -293,26 +289,25 @@ admin.oauth2.tenant-name-pattern @if (entityForm.get('mapperConfig.basic.tenantNamePattern').hasError('required')) { - + {{ 'admin.oauth2.tenant-name-pattern-required' | translate }} } @if (entityForm.get('mapperConfig.basic.tenantNamePattern').hasError('maxlength')) { - + {{ 'admin.oauth2.tenant-name-pattern-max-length' | translate }} }
- + admin.oauth2.customer-name-pattern @if (entityForm.get('mapperConfig.basic.customerNamePattern').hasError('maxlength')) { @@ -323,7 +318,7 @@
- + admin.oauth2.default-dashboard-name @if (entityForm.get('mapperConfig.basic.defaultDashboardName').hasError('maxlength')) { @@ -340,10 +335,9 @@
} @if (entityForm.get('mapperConfig.type').value === mapperType.CUSTOM && entityForm.get('mapperConfig.custom')) { -
+
- + admin.oauth2.url @if (entityForm.get('mapperConfig.custom.url').hasError('required')) { @@ -364,7 +358,7 @@
- + common.username @if (entityForm.get('mapperConfig.custom.username').hasError('maxlength')) { @@ -373,7 +367,7 @@ } - + common.password @if (entityForm.get('mapperConfig.custom.password').hasError('maxlength')) { diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html index fb72798997..dc0726e68d 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html @@ -17,7 +17,7 @@ -->
- + admin.oauth2.domain-name @if (entityForm.get('name').hasError('required')) { @@ -38,7 +38,7 @@
- + admin.oauth2.redirect-url-template
-
- + admin.jwt.expiration-time } - + admin.jwt.refresh-expiration-time - + admin.sms-message @if (sendTestSmsFormGroup.get('message').hasError('required')) { diff --git a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html index a723b89043..a83292d2ea 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html @@ -67,7 +67,7 @@
} - + admin.slack-api-token diff --git a/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.html index 07789156bb..06031c8a69 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.html @@ -38,7 +38,7 @@ {{ 'admin.trendz-enable' | translate }} - + admin.trendz-url @if (trendzSettingsForm.get('trendzUrl').hasError('required')) { @@ -52,7 +52,7 @@ } - + admin.trendz-api-key @if (trendzSettingsForm.get('apiKey').hasError('pattern')) { diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts index b0606b9c5b..75dd645b40 100644 --- a/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts @@ -26,7 +26,7 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { BreadCrumbConfig } from '@shared/components/breadcrumb'; -import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { AlarmRulesService } from '@core/http/alarm-rules.service'; import { TranslateService } from '@ngx-translate/core'; import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; @@ -40,7 +40,7 @@ import { AlarmRulesTableConfig } from '@home/components/alarm-rules/alarm-rules- export const AlarmRulesTableConfigResolver: ResolveFn = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot, - calculatedFieldsService = inject(CalculatedFieldsService), + alarmRulesService = inject(AlarmRulesService), translate = inject(TranslateService), dialog = inject(MatDialog), store = inject(Store), @@ -52,7 +52,7 @@ export const AlarmRulesTableConfigResolver: ResolveFn = router = inject(Router), ) => { return new AlarmRulesTableConfig( - calculatedFieldsService, + alarmRulesService, translate, dialog, datePipe, diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts index ae4ee4001d..c26624bebc 100644 --- a/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts @@ -18,9 +18,9 @@ import { Component } from '@angular/core'; import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models'; import type { - CalculatedFieldsTableConfig, - CalculatedFieldsTableEntity -} from '@home/components/calculated-fields/calculated-fields-table-config'; + AlarmRulesTableConfig, + AlarmRuleTableEntity +} from '@home/components/alarm-rules/alarm-rules-table-config'; import { debugCfActionEnabled } from '@shared/models/calculated-field.models'; @Component({ @@ -29,7 +29,7 @@ import { debugCfActionEnabled } from '@shared/models/calculated-field.models'; styleUrls: [], standalone: false }) -export class AlarmRulesTabsComponent extends EntityTabsComponent { +export class AlarmRulesTabsComponent extends EntityTabsComponent { readonly DebugEventType = DebugEventType; readonly EventType = EventType; @@ -43,8 +43,7 @@ export class AlarmRulesTabsComponent extends EntityTabsComponent { - }); + (this.entitiesTableConfig as AlarmRulesTableConfig).getTestScriptDialog(this.entity, JSON.parse(event.arguments)) + .subscribe((_expression) => { }); }; } diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.html index f53a8b37ca..691a5250cd 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.html @@ -36,6 +36,7 @@
{{ labelText | translate }} diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html index db5287fd68..76349148fd 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html @@ -19,7 +19,7 @@ @if (transportType.isActive) {
- + device-profile.transport-type @for (type of deviceTransportTypes; track type) { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html index 992a88ab16..ec05b5ae77 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html @@ -17,7 +17,7 @@ -->
- + device-profile.snmp.host @if (snmpDeviceTransportConfigurationFormGroup.get('host').hasError('required') || @@ -27,7 +27,7 @@ } - + device-profile.snmp.port @if (snmpDeviceTransportConfigurationFormGroup.get('port').hasError('required')) { @@ -43,7 +43,7 @@ }
- + device-profile.snmp.protocol-version @for (snmpDeviceProtocolVersion of snmpDeviceProtocolVersions; track snmpDeviceProtocolVersion) { @@ -60,7 +60,7 @@ @if (!isV3protocolVersion()) {
- + device-profile.snmp.community @if (snmpDeviceTransportConfigurationFormGroup.get('community').hasError('required') || @@ -75,7 +75,7 @@ @if (isV3protocolVersion()) {
- + device-profile.snmp.user-name @if (snmpDeviceTransportConfigurationFormGroup.get('username').hasError('required') || @@ -85,7 +85,7 @@ } - + device-profile.snmp.security-name @if (snmpDeviceTransportConfigurationFormGroup.get('securityName').hasError('required') || @@ -97,7 +97,7 @@
- + device-profile.snmp.authentication-protocol @for (snmpAuthenticationProtocol of snmpAuthenticationProtocols; track snmpAuthenticationProtocol) { @@ -112,7 +112,7 @@ } - + device-profile.snmp.authentication-passphrase @if (snmpDeviceTransportConfigurationFormGroup.get('authenticationPassphrase').hasError('required') || @@ -124,7 +124,7 @@
- + device-profile.snmp.privacy-protocol @for (snmpPrivacyProtocol of snmpPrivacyProtocols; track snmpPrivacyProtocol) { @@ -139,7 +139,7 @@ } - + device-profile.snmp.privacy-passphrase @if (snmpDeviceTransportConfigurationFormGroup.get('privacyPassphrase').hasError('required') || @@ -151,11 +151,11 @@
- + device-profile.snmp.context-name - + device-profile.snmp.engine-id diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html index 2d2eaa32c9..8368ffe49b 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html @@ -30,6 +30,7 @@ label="{{ 'attribute.latest-telemetry' | translate }}" #telemetryTab="matTab"> diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html index 170ae4d6bf..06c1470ff6 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html @@ -110,27 +110,24 @@
entity-view.attributes-propagation-hint
- - - @@ -142,11 +139,10 @@
entity-view.timeseries-data-hint
- diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html index 697dcf86af..3f3368e3b2 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -39,7 +39,7 @@
- + user.email @if (profile.get('email').hasError('required')) { @@ -53,20 +53,21 @@ } - + user.first-name - + user.last-name - + language.language @@ -76,7 +77,7 @@ } - + unit.unit-system @@ -90,6 +91,7 @@
- + rulechain.name @if (createNestedRuleChainFormGroup.get('name').hasError('required')) { @@ -47,7 +47,7 @@ }
- + rulechain.description diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html index d243baaa29..f6e244a81b 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + rulenode.link-labels @for (label of labels; track label) { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html index a59ad5c547..a83c1f8b17 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html @@ -44,8 +44,7 @@ [additionalActionConfig]="additionalActionConfig" class="mr-2" formControlName="debugSettings" - [entityType]="entityType.RULE_NODE" - /> + [entityType]="entityType.RULE_NODE"/> @if (isSingleton()) {