diff --git a/application/pom.xml b/application/pom.xml index 67b61a5737..9489c627a1 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -572,6 +572,29 @@ ${project.build.directory} + + org.apache.maven.plugins + maven-enforcer-plugin + + + verify-openapi-spec-generated + verify + + enforce + + + + + + ${project.build.directory}/openapi.json + + OpenAPI spec was not generated — target/openapi.json is missing. The springdoc-openapi-maven-plugin logs HTTP failures but does not fail the build; scan the log above for "An error has occured" or a 5xx response from /v3/api-docs/thingsboard and fix the underlying issue (e.g. duplicate @Schema names rejected by SwaggerConfiguration). + + + + + + org.apache.maven.plugins maven-surefire-plugin diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 81cb2fdbb1..11d9bfbb9d 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -24,6 +24,7 @@ "cards.html_value_card", "cards.markdown_card", "cards.simple_card", - "unread_notifications" + "unread_notifications", + "html_container" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/html_widgets.json b/application/src/main/data/json/system/widget_bundles/html_widgets.json index c8215d7fe1..e242bb00e7 100644 --- a/application/src/main/data/json/system/widget_bundles/html_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/html_widgets.json @@ -11,6 +11,7 @@ "widgetTypeFqns": [ "cards.html_card", "cards.html_value_card", - "cards.markdown_card" + "cards.markdown_card", + "html_container" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json new file mode 100644 index 0000000000..25b9e5ac5d --- /dev/null +++ b/application/src/main/data/json/system/widget_types/html_container.json @@ -0,0 +1,52 @@ +{ + "fqn": "html_container", + "name": "HTML Container", + "deprecated": false, + "image": "tb-image;/api/images/system/html-container.png", + "description": "Configurable HTML, CSS and JavaScript widget with access to the Widget API via WidgetContext and the ability to load external resources or modules. Supports two modes: plain HTML (regular HTML/JS bound to the widget container) and Angular (Angular template with bound variables and functions). Use for custom complex visualizations or actions when system widgets are not enough.", + "descriptor": { + "type": "static", + "sizeX": 9.5, + "sizeY": 5.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n", + "settingsDirective": "tb-html-container-widget-settings", + "hasBasicMode": true, + "basicModeDirective": "tb-html-container-basic-config", + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgba(255, 255, 255, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0\",\"settings\":{\"type\":\"PLAIN\",\"html\":\"\",\"css\":\"\",\"js\":\"\",\"resources\":[]},\"title\":\"HTML Container\",\"dropShadow\":false,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\"}" + }, + "resources": [ + { + "link": "/api/images/system/html-container.png", + "title": "\"HTML Container\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "html-container.png", + "publicResourceKey": "0CBg8htTwiFsclrm44sIp5pQNS4MM35l", + "mediaType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAYAAABJ/yOpAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAADt9JREFUeAHtnU9oHccdx39OnCCVOrYcCESmCKxDITYkUB9aYkNb4tySQ0rsWxzowT600Bwa4h56yMW9tb05t6a3+Bjf7EAP8qEF6RCIAg2VSgtRoCVSHAfbKcbtfrrzy47Wu6NdvSe9Wfn7gZH2zZvdtzsz3/m785t9VrK/cE8W7vHCPWJiUhwI/2+ZmCS3C7dRuHt8QBzfKdwhkzgmzcHgxORAA09YqYn9iGOmcDcL96UJIe5bqQWE8iR/vlW4r0wIEYNIHvcm1X0TQsSgiUfU5xAigQQiRAIJRIgEEogQCSQQIRJIIEIkkECESCCBCJFAAhEigQQiRAIJRIgEEogQCfabEP1gecTZwh2N/FhcdKkl/MVwjrNSuCvhnOxRDSL6csE2i6Mv8+Eag0ACEX1AGDM2Olxj3gaAmliiD4ejY28qbcXl6Phc4WbD8TiEtuMMoQYhQs8XbqrHOVPhnFkTO8lG5LqEuWsDI3eBkMFpr3q7tYtIpqJzJBIxEjkLxMUxFX0+0eG841aJYtokEjECuQqkLg64XrgbHc5dDGEdiURsmxwF0iaO690v8UB4iURsi9wEMg5xtJ0nkYje5CSQcYqj7XyJZDRGmSCs87QNgFwEshPiaLuORLI9GBXsMkiS4nZ0fMoGMFn4qJU2eW/a5NhJcTir4b8nyGOFe7Zwn1hehqI9Dr62PDhduGOF+3E4jiF9PrN+ENexyDgm/Z+yMm1WLS8OTVoguyEOZwgiyU0gpM2cbZ71ZsKP9PmL9ccnDGleTQc/F8e87Uy6j8IhvYsltsMoM+IIY58NhEnXIJTcfy3cc1a9F+Yl/Lir29O2uZlwp3DvFG7N8iG3GgRIBwTxVPhMBj8W/Pu+sk6T6hWrag/gna5lK2tyNbEa2A2RDEEckJtAVoP70MqMHPcfpoJ/H162zc01XmT8wPIUB2TTxCKjEllx1V3P1NtlKOLIHTJw/CbDtI3GouUpik3k1AfZCZFIHONlnPGWvTggt076OEUicYiRyXEUaxwikTjEWMh1mLdNJF1mck+axCHGRM7zIHWRfFq4jzqct2SVGCQOMRK5TxS6SBhiJKN3maByUayaxCFGZAhGG8jg7/Q75f8iuWxiJ2GYt/4KShODMM7QhqyaiD6sR8e8R3cxHKcMx/E+V5NIZDhO7Dlotq7b6HCNFRsAEojoi/fvtov3JwfDnIlcOBicyIM51SBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRIMfX3bG3xFqD+HVoXpe+Y82mZu6E//Vz/DwL/n6Nwe2TlymkE5t6Eqf1eHfD4Gsdw2dLjgJxe71vRn5sXI8dJdakk9HdwBqZnaW4HxfujD1o19f3474UrrFi+dl/HSKeRry2TloQ/+9bmTZvRP689XslET57hrZgylcJIgY3ogwIh5Ip3r/C9/QeTGk1INjO+ZqVhuRcFJgPfd5KuwGIghqdBVWI4WSDP+GzXxOScx9kJnJdoDo/YpXZUkSzaGLcYJmdNPG4pQD6g5U1OdshuKV2CqxfWymMlQb/QSyYyrkGuRAdxwnSBiXTQuGesbIqRyCUWNlv0jIwvB8Y9+V8ARU1OoJ51UoL7r6h6vUQ3v2v20AKr5wFEq9xvtAhPAlHlf9W4b6wMgHGsTxUbMabrPQlXCRYe/cO+WJwNHFft2qZ7kJwhD0brqMm1i5D9U01j+G4JRM7ARmbDP9i+EztTp+EESoy/svB3wUzFb53/5XIP3v2olUTSq8XrEyI+qaTsUnSwa2Nzog/Fu41K2sDYESK+EQ47P/oFjDpl9AZ3wjh3d/3BBkEWpOeD0Nbk942gDJlzTVEm3+uzMkulhiFtiH0uz39s0WvmgiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBLyseMC0q1EuDOlN14eBA6pBhEhADXKrcDdN5ITSIw8OqQYRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoE20BHjgLX0x63cItp3wWVznbXgBrtXvQQiRgFhsDnncUsbnPDtoAcnFAlEbBffzrmLJZYTITz71g9m8054tHCHTEYCcsEz29eWN6cKd8Y2F7DsXMs+9X8OjqbVvcI9Fb5/rHDPWblV9z9tGBwaSg0yW7iT0Wc2p19sCPM9q9rAbTSdK7pDPL8UfWarZ7aFrjefiGcEw064bA19OPjTJGMv+xUbAEMRCCXriZpfnMlJhAvWvbpHSNojfXuci44RB/GY2r0W4fzeSpHMBj9qn9/aAHa93SvDvGT6PlYJ5619j2/RDvHs8bZuZc3RJZPTrHonnGPhGidsAGgeRPQhztRto1JT1lxYIZIPos/HbADs1VEsSrZPa360m4+b6AuZ/VRwccZfagl7IRxftgdrl+XgR7hZGwB7VSCUVvXSjQT9rBZGpCETN/XtmjrYLg7P+BzXRUKcr4cwDKbQ1Mp6bmSvCuTpBj8SipEVEmiwM7u7SNPAB8Lw2fGYujjMKnHVRRIf02RbbrheNuwVgdSr8pe3CP++lUOQop1YHBQqTPKtNoRrEofTJhLndHAr4frZFVx7pZPOkG+fUohE0V4c7dRHqxiB6isOp62ZFsOo4i8sw35JTjUIY+Px0GufeQratr8r3NFEGCYavZM+HVz24/ATostoFVBTx5magZEj4Xgt+m42hKWWoDDzPgzp7e9xkR7MsWQ1P5KTQEaZmzgazndIhHqiDmLcPRPiTJ96d+pqCEu6vWtlGhyJzqMp+3rhPg/HUH+LAQH6TDvXORX8siDnPkgslq1eHyFhTkefEUd9pEVNqu54XBGPqdLcJwBJK2qM+dr3NMvof6wnrsNvULP48DDzIxJIA0RULIqLibBbDdGeMTEKPlfRpVC5Y+n06NI3XLVM50dy6qT3eXltyUZH8yDt+Csh1Nzz9hCTk0CoVru8ZUu4eqnEeX06dh+ZOugp4n7HC7bzMG/ltVVWcyK59UF8rD1euulQ4i9b83AjzTNGP07a1v0Vwi6YSMEckb9aQg1Ck5VO9k4UKvzG2eizBLIF212rQca/amIcUBhds2rClRFAhIJw1hrCNmXqGdu6eebrfGaia2XTQQctuRVtIAZqYx8dJBO/1BCOAu1KOI6H1k9Y/6H17GbTJRCRwicJ6Ycc7hDe36vqOxKVepVlouwr3Fzh/mEiBw6G/znaCPDJ2PpkLqOP8agitc4xS7/V4Hi/ElHlOGgy980fkQUHrRKJmDxzWlEoRAIJRIgEEogQCSQQIRJIIEIkkECESCCBCJFAAhEigQQiRAIJRIgEEogQCSQQIRJIIEIkmKRAxrk/B9fquwaB8EM0BTRTc5N4Bpbj1uM7vqc9w6QWTBGJmPW5ZKOtIOM6Z62yEs7/y9btmqyzZkFQm2E0VsPdsfw2nSTe4ufDAEXTUmPWbrAu430bPyyTrS+1je+LdLhumS2f3Q65rCj0hTgsvtkInzFjeTf4swhnrSUcm5BeCtd5zcrEI8P43t1uRI7Ph8O1SNwr4b9ffzqEWbZqB6R1y3Ofb+49NpNEae5bPnjJ/oyVAnG7xfX4gPlwHt/dDceHrYoHi87rUlj4fbFYCkNwC+G68+E6cVzW09IS9+h5wZ8TpqPzreUe23637tfKpHa55eGopt16xvOF+8rK2oAdUFkHvT8cv164L62MgHo4IuRE8CMzs7LtEysj7s1w3g/Db5LwP7UykomYV6wS2U/C733fykyF/7NWRuS/g9sNuuxyS9ywNJVnd/vC3PfJ4P9LK3eZdQPUfM/z/DycTzji4l9WGoz+bvj+VDjnQLjedHQevzUf/JdCWH5rrXZfS+EcnuMHhftT4X5k5ZJddrnFCMSH1pzmbuT6llVphv/F8Dw8x89sc5rNhvtw49jz4RmWwm89G57nRSsLCtL8WM3vnrWTxS63NBG4URKNmydRl8MxD0pp9J6ViRGHwzQQAqO2IFLOhO9pUpyyqumxFL7nfDdyDbGpUkqoK1Y1/bAzS8RvWJ77evu2AUCTkqYMmd1LbZ6V5z4cviPsejjmGc9ZZfrIm6QUQMtReBfXuyEcme/kFveFUQfi+Ei4zt1wLd/xi0zsafd3K9PLbZSdDefcCPdxztJbVPwtXJfr/TccT4d7OBru9VJ079yTC+qadeyz5iAQEobmwMdW2bQi4ohYHt6r4NO1cN4kcoF5wiMOakXfTYrMgmgQyVbrnr3K3cq21qSpN7GAhPdapI4PYrj9qXinrVQzg/glTonjLnGCKG+H31qwqsNODeJxz38KLtLzvFUinInCbFjVhGrDw3pNB9509vXwZ6PrWfgdfpfCZM0qayyt7OYoFhn3fDh29XLjZFw2d6T08PaldwApDdxOVlM4qslz0bUoJUlUMoBHEiLbylavJ/7R6PfNhjPKRUZCHIvWXMoTH8TP5eCuWTe8j0KG7lKTes3F771olfHrq+F3KagQmzevfxPOI2297+K/G+8E5oVhk0h9Sze+9z3ZPf3es6qG5fqnwz3wu0esskTfym7WIEQM7c63rawSY3P4KP1O8Pft00hEmgxeIi7UwhEhPPhL4ZpuRJnEIFHYC/1iCEdJkSoBvWk1bVXTgMSmHfuF5WeJ8Xx0vBj95znfslIk3g4nLM0P4vXtEHbBuhnoI1NR+hM3PqDRpdDwtON3yKSvWmlBh3TDGjzNrXNWDRAsRs8VpxncCNdqM5K9Gp71V1Y9GwK9btXI2rpVgnvDqr0SO9mD3m2rJk1Ww7taEm8L56VHk/9WeFVvI9zXuBi3VZP6/W/3ecYxtzEzgt8oeWOmY7gm5ibRB7nb0a/rue4/ynXbwg/dwPW4nmccw9wbI/h1ue/U/iNdwjUiy4p7YDJL7Bx6F0uIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCBHLfJBQh6jzif/5j5QISIUTFtwt3G4F8XrgnrBSJahLxsIMG/KXRjX3Bk3eyfL22mBxek98yMSnoctCqouK49z8r21UdWLc82QAAAABJRU5ErkJggg==", + "public": true + } + ], + "scada": false, + "tags": [ + "html", + "css", + "javascript", + "custom", + "script", + "code", + "container", + "angular", + "template", + "external resources", + "widget api", + "advanced", + "custom visualization", + "custom action", + "web", + "markup" + ] +} \ No newline at end of file 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/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index a2dc9b50f4..b69719f1a0 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -485,7 +485,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) { state.setCtx(ctx, actorCtx); - state.init(false); if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isCfHasRelationPathQuerySource()) { GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state; @@ -494,6 +493,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM Map arguments = fetchArguments(ctx); state.update(arguments, ctx); + state.init(false); state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize()); states.put(ctx.getCfId(), state); 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..db867234e2 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; @@ -22,6 +23,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; import io.swagger.v3.core.jackson.ModelResolver; import io.swagger.v3.core.util.Json; import io.swagger.v3.oas.models.Components; @@ -62,12 +64,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 +80,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 +120,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; @@ -160,6 +167,9 @@ public class SwaggerConfiguration { if (StringUtils.isEmpty(apiVersion)) { apiVersion = appVersion; } + if (apiVersion != null && apiVersion.endsWith("-SNAPSHOT")) { + apiVersion = apiVersion.substring(0, apiVersion.length() - "-SNAPSHOT".length()); + } Info info = new Info() .title(title) @@ -294,6 +304,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 +349,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()) { @@ -350,12 +377,26 @@ public class SwaggerConfiguration { ._enum(Arrays.stream(ThingsboardErrorCode.values()) .map(ThingsboardErrorCode::getErrorCode) .collect(Collectors.toList())); - openAPI.getComponents() - .addSchemas("LoginRequest", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginRequest.class)).schema) - .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); + Components components = openAPI.getComponents(); + registerSchema(components, "LoginRequest", LoginRequest.class); + registerSchema(components, "LoginResponse", LoginResponse.class); + registerSchema(components, "ThingsboardErrorResponse", ThingsboardErrorResponse.class); + registerSchema(components, "ThingsboardCredentialsExpiredResponse", ThingsboardCredentialsExpiredResponse.class); + components.addSchemas("ThingsboardErrorCode", errorCodeSchema); + registerSchema(components, "AiChatModelConfig", AiChatModelConfig.class); + } + + private static void registerSchema(Components components, String name, Class cls) { + ResolvedSchema resolved = ModelConverters.getInstance() + .readAllAsResolvedSchema(new AnnotatedType().type(cls)); + components.addSchemas(name, resolved.schema); + if (resolved.referencedSchemas != null) { + resolved.referencedSchemas.forEach((refName, refSchema) -> { + if (components.getSchemas() == null || !components.getSchemas().containsKey(refName)) { + components.addSchemas(refName, refSchema); + } + }); + } } private OperationCustomizer operationCustomizer() { @@ -372,6 +413,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 +461,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); @@ -433,6 +545,12 @@ public class SwaggerConfiguration { reorderSchemaProperties(schema, propOrder); }); + // Synthesize a request-body example for every schema that uses a discriminator. + // Without this, Swagger UI shows only the discriminator-property field for + // polymorphic types (the parent schema doesn't know which oneOf branch to pick). + // We resolve the first declared subtype and inline its full property tree. + schemas.forEach((schemaName, schema) -> fillDiscriminatorExample(schema, schemas)); + // Fix polymorphic request/response bodies: replace inline oneOf with base type $ref paths.values().stream() .flatMap(pathItem -> pathItem.readOperationsMap().values().stream()) @@ -505,6 +623,22 @@ public class SwaggerConfiguration { if (baseType != null) { return baseType; } + + // Check if other oneOf items extend this candidate via allOf (parent-child without discriminator) + if (candidate != null) { + boolean isParent = oneOfSchemas.stream() + .filter(s -> s.get$ref() != null && !s.get$ref().equals(ref)) + .anyMatch(s -> { + String otherName = s.get$ref().substring(s.get$ref().lastIndexOf('/') + 1); + Schema otherSchema = schemas.get(otherName); + return otherSchema != null && otherSchema.getAllOf() != null && + otherSchema.getAllOf().stream().anyMatch( + a -> a.get$ref() != null && a.get$ref().endsWith("/" + refName)); + }); + if (isParent) { + return refName; + } + } } return null; } @@ -546,6 +680,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 @@ -737,6 +881,145 @@ public class SwaggerConfiguration { } } + private static final int MAX_EXAMPLE_DEPTH = 4; + + /** + * If {@code schema} has a discriminator, populate examples for the parent and every + * concrete subtype it maps to. Each subtype gets its own example with the discriminator + * field set to the mapping value that points at it, so fields typed as a specific + * subtype (e.g. {@code EntityView.id} → {@code EntityViewId}) resolve to a correct + * example without falling back to the parent's. + */ + @SuppressWarnings("unchecked") + private void fillDiscriminatorExample(Schema schema, Map allSchemas) { + var discriminator = schema.getDiscriminator(); + if (discriminator == null || discriminator.getMapping() == null || discriminator.getMapping().isEmpty()) { + return; + } + // 1. Populate an example on each mapped subtype. + for (var entry : discriminator.getMapping().entrySet()) { + String discriminatorValue = entry.getKey(); + String subtypeRef = entry.getValue(); + String subtypeName = subtypeRef.substring(subtypeRef.lastIndexOf('/') + 1); + Schema subtype = allSchemas.get(subtypeName); + if (subtype == null || subtype.getExample() != null) { + continue; + } + Map example = new LinkedHashMap<>(); + buildSchemaExample(subtypeName, allSchemas, example, new HashSet<>(), 0); + if (example.isEmpty()) { + continue; + } + example.put(discriminator.getPropertyName(), discriminatorValue); + subtype.setExample(example); + } + // 2. Mirror a subtype's example onto the parent so a field typed as the parent + // interface still gets a complete example. Prefer the subtype whose mapping key + // matches the example declared on the discriminator property itself + // (e.g. EntityId.getEntityType() has example = "DEVICE" → mirror DeviceId, not + // the alphabetically first AdminSettingsId). Fall back to the first mapping entry. + if (schema.getExample() == null) { + String preferredValue = null; + if (schema.getProperties() != null) { + Schema discProp = (Schema) schema.getProperties().get(discriminator.getPropertyName()); + if (discProp != null && discProp.getExample() != null) { + preferredValue = discProp.getExample().toString(); + } + } + String chosenRef = preferredValue != null ? discriminator.getMapping().get(preferredValue) : null; + if (chosenRef == null) { + chosenRef = discriminator.getMapping().values().iterator().next(); + } + String chosenSubtypeName = chosenRef.substring(chosenRef.lastIndexOf('/') + 1); + Schema chosenSubtype = allSchemas.get(chosenSubtypeName); + if (chosenSubtype != null && chosenSubtype.getExample() != null) { + schema.setExample(chosenSubtype.getExample()); + } + } + } + + @SuppressWarnings("unchecked") + private void buildSchemaExample(String schemaName, Map allSchemas, + Map result, Set visited, int depth) { + if (depth > MAX_EXAMPLE_DEPTH || !visited.add(schemaName)) { + return; + } + Schema schema = allSchemas.get(schemaName); + if (schema == null) { + return; + } + // Walk parents first so own properties (added later) override inherited entries. + if (schema.getAllOf() != null) { + String selfRef = "#/components/schemas/" + schemaName; + for (Schema allOfElement : schema.getAllOf()) { + String ref = allOfElement.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + buildSchemaExample(refName, allSchemas, result, visited, depth); + // If the parent uses a discriminator, this schema is one of its mapping + // targets — override the discriminator field with the value that points + // back at us (e.g. EntityViewId → entityType: "ENTITY_VIEW", not "ADMIN_SETTINGS"). + Schema parentSchema = allSchemas.get(refName); + if (parentSchema != null && parentSchema.getDiscriminator() != null + && parentSchema.getDiscriminator().getMapping() != null) { + parentSchema.getDiscriminator().getMapping().entrySet().stream() + .filter(e -> selfRef.equals(e.getValue())) + .map(Map.Entry::getKey) + .findFirst() + .ifPresent(value -> result.put(parentSchema.getDiscriminator().getPropertyName(), value)); + } + } else if (allOfElement.getProperties() != null) { + allOfElement.getProperties().forEach((k, v) -> + result.put(k, sampleValue((Schema) v, allSchemas, visited, depth + 1))); + } + } + } + if (schema.getProperties() != null) { + schema.getProperties().forEach((k, v) -> + result.put(k, sampleValue((Schema) v, allSchemas, visited, depth + 1))); + } + } + + @SuppressWarnings("unchecked") + private Object sampleValue(Schema propSchema, Map allSchemas, + Set visited, int depth) { + if (propSchema == null) { + return null; + } + if (propSchema.getExample() != null) { + return propSchema.getExample(); + } + String ref = propSchema.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + Schema refSchema = allSchemas.get(refName); + if (refSchema != null && refSchema.getExample() != null) { + return refSchema.getExample(); + } + if (depth >= MAX_EXAMPLE_DEPTH) { + return Map.of(); + } + Map nested = new LinkedHashMap<>(); + buildSchemaExample(refName, allSchemas, nested, new HashSet<>(visited), depth + 1); + return nested; + } + if (propSchema.getEnum() != null && !propSchema.getEnum().isEmpty()) { + return propSchema.getEnum().get(0); + } + String type = propSchema.getType(); + if (type == null) { + return null; + } + return switch (type) { + case "string" -> "string"; + case "integer", "number" -> 0; + case "boolean" -> false; + case "array" -> List.of(); + case "object" -> Map.of(); + default -> null; + }; + } + @SuppressWarnings("unchecked") private void deduplicateAllOfProperties(Schema schema, Map allSchemas, Set ownProps) { if (schema.getAllOf() == null) { @@ -799,7 +1082,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 +1193,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 +1235,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/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 50766106d5..4a20e2fcd2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -518,11 +518,11 @@ public class AssetController extends BaseController { return checkNotNull(filteredResult); } - @ApiOperation(value = "Import the bulk of assets (processAssetsBulkImport)", + @ApiOperation(value = "Import the bulk of assets (processAssetBulkImport)", notes = "There's an ability to import the bulk of assets using the only .csv file.") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @PostMapping("/asset/bulk_import") - public BulkImportResult processAssetsBulkImport(@RequestBody BulkImportRequest request) throws Exception { + public BulkImportResult processAssetBulkImport(@RequestBody BulkImportRequest request) throws Exception { SecurityUser user = getCurrentUser(); return assetBulkImportService.processBulkImport(request, user); } 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/DomainController.java b/application/src/main/java/org/thingsboard/server/controller/DomainController.java index 04e750d0e4..c34bbe6ac9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DomainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DomainController.java @@ -93,19 +93,19 @@ public class DomainController extends BaseController { tbDomainService.updateOauth2Clients(domain, oAuth2ClientIds, getCurrentUser()); } - @ApiOperation(value = "Get Domain infos (getTenantDomainInfos)", notes = SYSTEM_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Get Domain infos (getDomainInfos)", notes = SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") @GetMapping(value = "/domain/infos") - public PageData getTenantDomainInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) - @RequestParam int pageSize, - @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) - @RequestParam int page, - @Parameter(description = "Case-insensitive 'substring' filter based on domain's name") - @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION) - @RequestParam(required = false) String sortProperty, - @Parameter(description = SORT_ORDER_DESCRIPTION) - @RequestParam(required = false) String sortOrder) throws ThingsboardException { + public PageData getDomainInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = "Case-insensitive 'substring' filter based on domain's name") + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.DOMAIN, Operation.READ); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); return domainService.findDomainInfosByTenantId(getTenantId(), pageLink); diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java index a1d6f667f9..5e51e0bbb6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java @@ -116,19 +116,19 @@ public class OAuth2Controller extends BaseController { return tbOauth2ClientService.save(oAuth2Client, getCurrentUser()); } - @ApiOperation(value = "Get OAuth2 Client infos (findTenantOAuth2ClientInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Get OAuth2 Client infos (findOAuth2ClientInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/oauth2/client/infos") - public PageData findTenantOAuth2ClientInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) - @RequestParam int pageSize, - @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) - @RequestParam int page, - @Parameter(description = "Case-insensitive 'substring' filter based on client's title") - @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION) - @RequestParam(required = false) String sortProperty, - @Parameter(description = SORT_ORDER_DESCRIPTION) - @RequestParam(required = false) String sortOrder) throws ThingsboardException { + public PageData findOAuth2ClientInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = "Case-insensitive 'substring' filter based on client's title") + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); return oAuth2ClientService.findOAuth2ClientInfosByTenantId(getTenantId(), pageLink); } 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/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java index 1796e959ab..b919b664e5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -549,8 +549,7 @@ public class UserController extends BaseController { userSettingsService.deleteUserSettings(currentUser.getTenantId(), currentUser.getId(), type, Arrays.asList(paths.split(","))); } - @ApiOperation(value = "Get information about last visited and starred dashboards (getUserDashboardsInfo)", - notes = "Fetch the list of last visited and starred dashboards. Both lists are limited to 10 items." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/user/dashboards") public UserDashboardsInfo getUserDashboardsInfo() throws ThingsboardException { @@ -558,6 +557,14 @@ public class UserController extends BaseController { return userSettingsService.findUserDashboardsInfo(currentUser.getTenantId(), currentUser.getId()); } + @ApiOperation(value = "Get information about last visited and starred dashboards (getLastVisitedDashboards)", + notes = "Fetch the list of last visited and starred dashboards. Both lists are limited to 10 items." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/user/lastVisitedDashboards") + public UserDashboardsInfo getLastVisitedDashboards() throws ThingsboardException { + return getUserDashboardsInfo(); + } + @ApiOperation(value = "Report action of User over the dashboard (reportUserDashboardAction)", notes = "Report action of User over the dashboard. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java index cfc50543a1..a89d870bf0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java @@ -242,7 +242,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypes)", notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/widgetsBundles/{widgetsBundleId}/widgetTypes") + @GetMapping(value = "/widgetsBundle/{widgetsBundleId}/widgetTypes") public List getBundleWidgetTypes( @Parameter(description = "Widget Bundle Id", required = true) @PathVariable("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { 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/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java index 212d363280..15be6f3734 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.ai; import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.Futures; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.Content; import dev.langchain4j.data.message.TextContent; @@ -42,7 +43,12 @@ class AiChatModelServiceImpl implements AiChatModelService { @Override public > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest) { - ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + ChatModel langChainChatModel; + try { + langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + } catch (Throwable t) { + return FluentFuture.from(Futures.immediateFailedFuture(t)); + } if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) { chatRequest = prepareGithubChatRequest(chatRequest); } diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 28d696468c..8b569f052c 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -37,6 +37,7 @@ import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; @@ -58,6 +59,7 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Base64; @@ -69,6 +71,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { + validateBaseUrl(chatModelConfig.providerConfig().baseUrl()); return OpenAiChatModel.builder() .baseUrl(chatModelConfig.providerConfig().baseUrl()) .apiKey(chatModelConfig.providerConfig().apiKey()) @@ -86,6 +89,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) { AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig(); + validateBaseUrl(providerConfig.endpoint()); return AzureOpenAiChatModel.builder() .endpoint(providerConfig.endpoint()) .serviceVersion(providerConfig.serviceVersion()) @@ -177,8 +181,8 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur if (chatModelConfig.frequencyPenalty() != null) { generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); } - if (chatModelConfig.frequencyPenalty() != null) { - generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().floatValue()); + if (chatModelConfig.presencePenalty() != null) { + generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue()); } if (chatModelConfig.maxOutputTokens() != null) { generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); @@ -273,6 +277,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { + validateBaseUrl(chatModelConfig.providerConfig().baseUrl()); var builder = OllamaChatModel.builder() .baseUrl(chatModelConfig.providerConfig().baseUrl()) .modelName(chatModelConfig.modelId()) @@ -300,6 +305,10 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur return builder.build(); } + private static void validateBaseUrl(String url) { + SsrfProtectionValidator.validateUri(URI.create(url)); + } + private static Duration toDuration(Integer timeoutSeconds) { return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; } 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/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index 930d030d71..f9c06dbda7 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -179,6 +179,7 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { ruleState.setActive(null); AlarmCondition condition = rule.getCondition(); if (condition.hasSchedule() || (condition.getType() == AlarmConditionType.DURATION && !ruleState.isEmpty())) { + ruleState.cancelDurationCheckFuture(); reevalNeeded.set(true); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java index 19c48272cc..ada94dacda 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java @@ -256,10 +256,7 @@ public class AlarmRuleState { firstEventTs = 0L; lastCheckTs = 0L; duration = 0L; - if (durationCheckFuture != null) { - durationCheckFuture.cancel(true); - durationCheckFuture = null; - } + cancelDurationCheckFuture(); } public void setDurationCheckFuture(ScheduledFuture durationCheckFuture) { @@ -270,6 +267,13 @@ public class AlarmRuleState { this.durationCheckFuture = durationCheckFuture; } + public void cancelDurationCheckFuture() { + if (durationCheckFuture != null) { + durationCheckFuture.cancel(true); + durationCheckFuture = null; + } + } + public boolean isEmpty() { return eventCount == 0L && firstEventTs == 0L && lastCheckTs == 0L && durationCheckFuture == null; } 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/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index 96f58707b5..aaf066acdc 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -135,6 +135,10 @@ public class InstallScripts { return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); } + public Path getWidgetBundlesDir() { + return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); + } + public String getDataDir() { if (!StringUtils.isEmpty(dataDir)) { if (!Paths.get(this.dataDir).toFile().isDirectory()) { @@ -207,7 +211,7 @@ public class InstallScripts { public void loadSystemWidgets() { log.info("Loading system widgets"); Map widgetsBundlesMap = new HashMap<>(); - Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); + Path widgetBundlesDir = getWidgetBundlesDir(); try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) { dirStream.forEach( path -> { diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 2babc3feeb..3932aa9dcf 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -54,6 +54,7 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService public void upgradeDatabase() { log.info("Updating schema..."); loadSql(getSchemaUpdateFile("basic")); + loadSql(getSchemaUpdateFile("lts")); log.info("Schema updated."); } 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/subscription/TbAlarmCountSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java index 22fdd77d7a..5b4287acd1 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java @@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperation()); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index cf8d9b1139..27bd88fc1a 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -64,7 +64,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS private final RateLimitService rateLimitService; private final TbLogEntityActionService logEntityActionService; - protected static final List SUPPORTED_ENTITY_TYPES = List.of( + public static final List SUPPORTED_ENTITY_TYPES = List.of( EntityType.CUSTOMER, EntityType.RULE_CHAIN, EntityType.TB_RESOURCE, EntityType.DASHBOARD, EntityType.ASSET_PROFILE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE, EntityType.DEVICE, @@ -131,7 +131,10 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS @Override public Comparator getEntityTypeComparatorForImport() { - return Comparator.comparing(SUPPORTED_ENTITY_TYPES::indexOf); + return Comparator.comparingInt(type -> { + int index = SUPPORTED_ENTITY_TYPES.indexOf(type); + return index >= 0 ? index : Integer.MAX_VALUE; + }); } @SuppressWarnings("unchecked") 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..a256888107 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 @@ -28,7 +28,10 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; @@ -40,7 +43,12 @@ 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.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @@ -48,7 +56,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 @@ -64,6 +72,8 @@ public class SystemPatchApplier { private final InstallScripts installScripts; private final DatabaseSchemaSettingsService schemaSettingsService; private final WidgetTypeService widgetTypeService; + private final WidgetsBundleService widgetsBundleService; + private final ImageService imageService; @PostConstruct private void init() { @@ -91,11 +101,19 @@ public class SystemPatchApplier { } try { + updateLtsSqlSchema(); + updateSqlViews(); log.info("Updated sql database views"); - int updated = updateWidgetTypes(); - log.info("Updated {} widget types", updated); + WidgetTypeStats widgetStats = updateWidgetTypes(); + log.info("System widget types: {} created, {} updated", widgetStats.created(), widgetStats.updated()); + + int updatedBundles = updateWidgetBundles(); + log.info("System widget bundles: {} updated", updatedBundles); + + int createdImages = createMissingSystemImages(); + log.info("Created {} new system images", createdImages); schemaSettingsService.updateSchemaVersion(); log.info("System data patch update completed successfully"); @@ -119,17 +137,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() { @@ -142,20 +180,24 @@ public class SystemPatchApplier { } } - private int updateWidgetTypes() { + private WidgetTypeStats updateWidgetTypes() { + AtomicInteger created = new AtomicInteger(); AtomicInteger updated = new AtomicInteger(); Path widgetTypesDir = installScripts.getWidgetTypesDir(); if (!Files.exists(widgetTypesDir)) { log.trace("Widget types directory does not exist: {}", widgetTypesDir); - return 0; + return new WidgetTypeStats(0, 0); } try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { dirStream.forEach( path -> { try { - if (updateWidgetTypeFromFile(path)) { + WidgetTypeChange change = updateWidgetTypeFromFile(path); + if (change == WidgetTypeChange.CREATED) { + created.incrementAndGet(); + } else if (change == WidgetTypeChange.UPDATED) { updated.incrementAndGet(); } } catch (Exception e) { @@ -166,18 +208,26 @@ public class SystemPatchApplier { ); } - return updated.get(); + return new WidgetTypeStats(created.get(), updated.get()); } - private boolean updateWidgetTypeFromFile(Path filePath) { + private WidgetTypeChange updateWidgetTypeFromFile(Path filePath) { JsonNode json = JacksonUtil.toJsonNode(filePath.toFile()); WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class); + return saveOrUpdateSystemWidgetType(fileWidgetType); + } + + private WidgetTypeChange saveOrUpdateSystemWidgetType(WidgetTypeDetails fileWidgetType) { String fqn = fileWidgetType.getFqn(); + if (fqn == null || fqn.isBlank()) { + throw new RuntimeException("Widget type fqn is missing or blank: " + fileWidgetType.getName()); + } WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn); if (existingWidgetType == null) { - // We expect only update here, so it's probably never happening, but for test purpose leave it like this: - throw new RuntimeException("Widget type not found: " + fqn); + widgetTypeService.saveWidgetType(fileWidgetType); + log.trace("Created widget type: {}", fqn); + return WidgetTypeChange.CREATED; } if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) { existingWidgetType.setDescription(fileWidgetType.getDescription()); @@ -185,11 +235,128 @@ public class SystemPatchApplier { existingWidgetType.setDescriptor(fileWidgetType.getDescriptor()); widgetTypeService.saveWidgetType(existingWidgetType); log.trace("Updated widget type: {}", fqn); - return true; + return WidgetTypeChange.UPDATED; } log.trace("Widget type unchanged: {}", fqn); - return false; + return WidgetTypeChange.UNCHANGED; + } + + private int updateWidgetBundles() { + AtomicInteger updated = new AtomicInteger(); + Path widgetBundlesDir = installScripts.getWidgetBundlesDir(); + + if (!Files.exists(widgetBundlesDir)) { + log.trace("Widget bundles directory does not exist: {}", widgetBundlesDir); + return 0; + } + + try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { + dirStream.forEach(path -> { + try { + if (processWidgetBundleFile(path)) { + updated.incrementAndGet(); + } + } catch (Exception e) { + log.error("Unable to process widgets bundle from json: [{}]", path); + throw new RuntimeException("Unable to process widgets bundle from json", e); + } + }); + } + + return updated.get(); + } + + private boolean processWidgetBundleFile(Path filePath) { + JsonNode bundleJson = JacksonUtil.toJsonNode(filePath.toFile()); + if (bundleJson == null || !bundleJson.has("widgetsBundle")) { + throw new RuntimeException("Invalid widgets bundle json: " + filePath); + } + WidgetsBundle fileBundle = JacksonUtil.treeToValue(bundleJson.get("widgetsBundle"), WidgetsBundle.class); + String alias = fileBundle.getAlias(); + if (alias == null || alias.isBlank()) { + throw new RuntimeException("Widgets bundle alias is missing or blank: " + filePath); + } + + WidgetsBundle existingBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias); + if (existingBundle == null) { + log.warn("Widgets bundle '{}' not found in DB; bundle creation is not supported by the patch applier, skipping.", alias); + return false; + } + + if (bundleJson.has("widgetTypes")) { + throw new RuntimeException("Inline widgetTypes in bundle JSON are not supported by the patch applier; " + + "place widget definitions in widget_types/*.json and reference them via widgetTypeFqns: " + filePath); + } + List fileWidgetFqns = new ArrayList<>(); + if (bundleJson.has("widgetTypeFqns")) { + bundleJson.get("widgetTypeFqns").forEach(fqnJson -> fileWidgetFqns.add(fqnJson.asText())); + } + + boolean changed = false; + if (isWidgetsBundleChanged(existingBundle, fileBundle)) { + existingBundle.setTitle(fileBundle.getTitle()); + existingBundle.setDescription(fileBundle.getDescription()); + existingBundle.setImage(fileBundle.getImage()); + existingBundle.setOrder(fileBundle.getOrder()); + existingBundle.setScada(fileBundle.isScada()); + widgetsBundleService.saveWidgetsBundle(existingBundle); + log.trace("Updated widgets bundle metadata: {}", alias); + changed = true; + } + + List existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()); + LinkedHashSet mergedFqns = new LinkedHashSet<>(existingFqns); + if (mergedFqns.addAll(fileWidgetFqns)) { + widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, existingBundle.getId(), new ArrayList<>(mergedFqns)); + log.trace("Linked {} new widget fqn(s) to bundle: {}", mergedFqns.size() - existingFqns.size(), alias); + changed = true; + } + return changed; + } + + private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) { + // Image is intentionally NOT compared: the file always carries a base64 data URI, while the DB stores + // the system-image URL produced by ImageService.replaceBase64WithImageUrl on save. A naive string compare + // would always report a diff and re-save every system bundle on every patch run. Image content changes + // are out of scope for the patch applier — full reinstall covers them. + return !Objects.equals(existing.getTitle(), file.getTitle()) + || !Objects.equals(existing.getDescription(), file.getDescription()) + || !Objects.equals(existing.getOrder(), file.getOrder()) + || existing.isScada() != file.isScada(); + } + + private int createMissingSystemImages() { + AtomicInteger created = new AtomicInteger(); + Path imagesDir = Paths.get(installScripts.getDataDir(), InstallScripts.RESOURCES_DIR, "images"); + + if (!Files.exists(imagesDir)) { + log.warn("System images directory does not exist: {}", imagesDir); + return 0; + } + + Set existingKeys = imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + + try (Stream dirStream = listDir(imagesDir).filter(Files::isRegularFile)) { + dirStream.forEach(path -> { + String resourceKey = path.getFileName().toString(); + if (existingKeys.contains(resourceKey)) { + log.trace("System image already exists, skipping: {}", resourceKey); + return; + } + try { + byte[] data = Files.readAllBytes(path); + imageService.createOrUpdateSystemImage(resourceKey, data); + created.incrementAndGet(); + log.trace("Created system image: {}", resourceKey); + } catch (Exception e) { + log.error("Unable to create system image from file: [{}]", path); + throw new RuntimeException("Unable to create system image " + resourceKey, e); + } + }); + } + + return created.get(); } private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) { @@ -287,4 +454,8 @@ public class SystemPatchApplier { public record VersionInfo(int major, int minor, int maintenance, int patch) {} + public record WidgetTypeStats(int created, int updated) {} + + private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED } + } 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..6ceef7bc2e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -59,6 +59,14 @@ server: http2: # Enable/disable HTTP/2 support enabled: "${HTTP2_ENABLED:true}" + # HTTP response compression + compression: + # Enable/disable HTTP response compression + enabled: "${HTTP_COMPRESSION_ENABLED:false}" + # Minimum size (in bytes) required for a response before compression is applied + min_response_size: "${HTTP_COMPRESSION_MIN_RESPONSE_SIZE:2048}" + # Comma-separated list of MIME types that should be compressed + mime_types: "${HTTP_COMPRESSION_MIME_TYPES:text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml}" # Log errors with stacktrace when REST API throws an exception with the message "Please contact sysadmin" log_controller_error_stack_trace: "${HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE:false}" ws: @@ -469,6 +477,7 @@ sql: log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}" # Interval in milliseconds for printing the latest statistic information about the tenant log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}" + entity_data_query_nulls_order_strategy: "${SQL_ENTITY_DATA_QUERY_NULLS_ORDER_STRATEGY:default}" # Nulls ordering strategy for sql entity data query. Possible values: default, nulls_first, nulls_last. The default value is 'default', which means postgres default behavior will be applied: NULLS LAST for ASC and NULLS FIRST for DESC. The 'nulls_first' value means that NULL values will be ordered before non-NULL values regardless of the sort order. The 'nulls_last' value means that NULL values will be ordered after non-NULL values regardless of the sort order. query: key-filters-or-conditions: # Enable/disable OR conditions between key filters in entity data queries @@ -598,6 +607,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}" @@ -1443,6 +1464,15 @@ transport: branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.3.0}" # Fetch frequency in hours for gateways dashboard repository fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Interval in seconds for certificate reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters # Configures the standalone CoAP server bind address, port, DTLS encryption, and credential settings. @@ -1721,8 +1751,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record 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..9a001e0575 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); @@ -402,6 +401,57 @@ public class AlarmRulesTest extends AbstractControllerTest { }); } + @Test + public void testChangeDurationConditionFromStaticToDynamic() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + long staticDurationMs = 5000L; + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, staticDurationMs) + ); + + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", + arguments, createRules, null); + CalculatedFieldId alarmRuleId = alarmRule.getId(); + + // post telemetry to trigger condition and wait for the static-phase eval to produce a debug event, + // which guarantees firstEventTs > 0 in AlarmRuleState before we trigger REINIT + postTelemetry(deviceId, "{\"temperature\":50}"); + await().atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> getDebugEvents(alarmRuleId, 1), + events -> !events.isEmpty() && !events.get(0).getId().equals(latestEventId)); + + // update CF: add attribute argument and switch duration from static to dynamic + AlarmCalculatedFieldConfiguration configuration = alarmRule.getConfiguration(); + + Argument durationArgument = new Argument(); + durationArgument.setRefEntityKey(new ReferencedEntityKey("durationThreshold", + ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + durationArgument.setDefaultValue("-1"); + configuration.getArguments().put("durationThreshold", durationArgument); + + DurationAlarmCondition durationCondition = (DurationAlarmCondition) + configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition(); + durationCondition.setValue(new AlarmConditionValue<>(null, "durationThreshold")); + + alarmRule = saveAlarmRule(alarmRule); + + long dynamicDurationMs = 3000L; + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, + "{\"durationThreshold\":" + dynamicDurationMs + "}"); + + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + @Test public void testCreateAlarm_currentOwnerArgument() throws Exception { Argument temperatureArgument = new Argument(); @@ -424,12 +474,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 +512,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 +551,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 +560,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 +615,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 +646,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 +678,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 +712,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 +726,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 +769,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 +805,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 +815,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 +855,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 +888,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 +901,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 +911,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 +945,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/client/CalculatedFieldApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java index d17dd9d0f6..7594485c14 100644 --- a/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java +++ b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java @@ -17,10 +17,8 @@ package org.thingsboard.server.client; import org.junit.Test; import org.thingsboard.client.model.AlarmCalculatedFieldConfiguration; -import org.thingsboard.client.model.AlarmConditionValueAlarmRuleSchedule; -import org.thingsboard.client.model.AlarmRuleDefinition; -import org.thingsboard.client.model.AlarmRuleSimpleCondition; -import org.thingsboard.client.model.AlarmRuleSpecificTimeSchedule; +import org.thingsboard.client.model.AlarmConditionValueAlarmSchedule; +import org.thingsboard.client.model.AlarmRule; import org.thingsboard.client.model.AlarmSeverity; import org.thingsboard.client.model.Argument; import org.thingsboard.client.model.ArgumentType; @@ -30,7 +28,9 @@ import org.thingsboard.client.model.Device; import org.thingsboard.client.model.EntityType; import org.thingsboard.client.model.PageDataCalculatedField; import org.thingsboard.client.model.ReferencedEntityKey; +import org.thingsboard.client.model.SimpleAlarmCondition; import org.thingsboard.client.model.SimpleCalculatedFieldConfiguration; +import org.thingsboard.client.model.SpecificTimeSchedule; import org.thingsboard.client.model.TbelAlarmConditionExpression; import org.thingsboard.client.model.TimeSeriesOutput; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -198,12 +198,12 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest { // create rule: HIGH_TEMPERATURE when temp > 50 (TBEL expression) TbelAlarmConditionExpression createExpression = new TbelAlarmConditionExpression(); createExpression.setExpression("return temp > 50;"); - AlarmRuleSimpleCondition createCondition = new AlarmRuleSimpleCondition(); + SimpleAlarmCondition createCondition = new SimpleAlarmCondition(); createCondition.setExpression(createExpression); - AlarmRuleSpecificTimeSchedule specificTimeSchedule = new AlarmRuleSpecificTimeSchedule().addDaysOfWeekItem(3); - AlarmConditionValueAlarmRuleSchedule schedule = new AlarmConditionValueAlarmRuleSchedule().staticValue(specificTimeSchedule); + SpecificTimeSchedule specificTimeSchedule = new SpecificTimeSchedule().addDaysOfWeekItem(3); + AlarmConditionValueAlarmSchedule schedule = new AlarmConditionValueAlarmSchedule().staticValue(specificTimeSchedule); createCondition.setSchedule(schedule); - AlarmRuleDefinition createRule = new AlarmRuleDefinition(); + AlarmRule createRule = new AlarmRule(); createRule.setCondition(createCondition); createRule.setAlarmDetails("Temperature is too high: ${temp}"); config.setCreateRules(Map.of( @@ -213,9 +213,9 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest { // clear rule: when temp drops below 30 TbelAlarmConditionExpression clearExpression = new TbelAlarmConditionExpression(); clearExpression.setExpression("return temp < 30;"); - AlarmRuleSimpleCondition clearCondition = new AlarmRuleSimpleCondition(); + SimpleAlarmCondition clearCondition = new SimpleAlarmCondition(); clearCondition.setExpression(clearExpression); - AlarmRuleDefinition clearRule = new AlarmRuleDefinition(); + AlarmRule clearRule = new AlarmRule(); clearRule.setCondition(clearCondition); config.setClearRule(clearRule); @@ -236,8 +236,8 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest { assertEquals(cf.getName(), created.getName()); assertEquals(CalculatedFieldType.ALARM, created.getType()); AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) created.getConfiguration(); - AlarmConditionValueAlarmRuleSchedule createdSchedule = configuration.getCreateRules().get(AlarmSeverity.CRITICAL.name()).getCondition().getSchedule(); - AlarmRuleSpecificTimeSchedule staticSchedule = (AlarmRuleSpecificTimeSchedule) createdSchedule.getStaticValue(); + AlarmConditionValueAlarmSchedule createdSchedule = configuration.getCreateRules().get(AlarmSeverity.CRITICAL.name()).getCondition().getSchedule(); + SpecificTimeSchedule staticSchedule = (SpecificTimeSchedule) createdSchedule.getStaticValue(); assertEquals(Set.of(3), staticSchedule.getDaysOfWeek()); // get by id and verify configuration @@ -257,9 +257,9 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest { // update: add a second create rule for CRITICAL_TEMPERATURE TbelAlarmConditionExpression criticalExpression = new TbelAlarmConditionExpression(); criticalExpression.setExpression("return temp > 80;"); - AlarmRuleSimpleCondition criticalCondition = new AlarmRuleSimpleCondition(); + SimpleAlarmCondition criticalCondition = new SimpleAlarmCondition(); criticalCondition.setExpression(criticalExpression); - AlarmRuleDefinition criticalRule = new AlarmRuleDefinition(); + AlarmRule criticalRule = new AlarmRule(); criticalRule.setCondition(criticalCondition); fetchedConfig.putCreateRulesItem(AlarmSeverity.INDETERMINATE.name(), criticalRule); fetched.setConfiguration(fetchedConfig); diff --git a/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java index bd10a3deca..e9498a1c50 100644 --- a/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java +++ b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java @@ -69,7 +69,7 @@ public class DomainApiClientTest extends AbstractApiClientTest { } // list tenant domains with text search - PageDataDomainInfo filteredDomains = client.getTenantDomainInfos(100, 0, + PageDataDomainInfo filteredDomains = client.getDomainInfos(100, 0, "domain.", null, null); assertNotNull(filteredDomains); assertEquals(5, filteredDomains.getData().size()); @@ -97,7 +97,7 @@ public class DomainApiClientTest extends AbstractApiClientTest { client.getDomainInfoById(domainToDeleteId) ); - PageDataDomainInfo domainsAfterDelete = client.getTenantDomainInfos(100, 0, + PageDataDomainInfo domainsAfterDelete = client.getDomainInfos(100, 0, "domain.", null, null); assertEquals(4, domainsAfterDelete.getData().size()); } diff --git a/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java index 63f05710ae..2099f405f0 100644 --- a/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java +++ b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java @@ -90,7 +90,7 @@ public class Oauth2ApiClientTest extends AbstractApiClientTest { } // list tenant OAuth2 client infos - PageDataOAuth2ClientInfo clientInfos = client.findTenantOAuth2ClientInfos(100, 0, + PageDataOAuth2ClientInfo clientInfos = client.findOAuth2ClientInfos(100, 0, TEST_PREFIX + "OAuth2_" + timestamp, null, null); assertNotNull(clientInfos); assertEquals(5, clientInfos.getData().size()); @@ -130,7 +130,7 @@ public class Oauth2ApiClientTest extends AbstractApiClientTest { client.getOAuth2ClientById(clientToDeleteId) ); - PageDataOAuth2ClientInfo clientsAfterDelete = client.findTenantOAuth2ClientInfos(100, 0, + PageDataOAuth2ClientInfo clientsAfterDelete = client.findOAuth2ClientInfos(100, 0, TEST_PREFIX + "OAuth2_" + timestamp, null, null); assertEquals(4, clientsAfterDelete.getData().size()); } 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/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java index 099ef05752..100f44d4b7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -19,8 +19,13 @@ import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.core.type.TypeReference; import org.junit.Test; import org.springframework.test.web.servlet.ResultActions; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.dto.TbChatRequest; +import org.thingsboard.server.common.data.ai.dto.TbChatResponse; +import org.thingsboard.server.common.data.ai.dto.TbContent; +import org.thingsboard.server.common.data.ai.dto.TbUserMessage; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; @@ -34,6 +39,8 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -136,6 +143,68 @@ public class AiModelControllerTest extends AbstractControllerTest { assertThat(updatedModel.getExternalId()).isNull(); } + @Test + public void saveAiModel_whenBaseUrlIsPrivateIp_shouldReturnBadRequest() throws Exception { + // GIVEN + loginTenantAdmin(); + SsrfProtectionValidator.setEnabled(true); + + try { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://172.17.0.1:22/") + .apiKey("test-api-key") + .build()) + .modelId("gpt-4o") + .build(); + + AiModel model = AiModel.builder() + .tenantId(tenantId) + .name("SSRF test model") + .configuration(modelConfig) + .build(); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isBadRequest()); + } finally { + SsrfProtectionValidator.setEnabled(false); + } + } + + @Test + public void sendChatRequest_whenBaseUrlBlockedAtRuntime_shouldReturnFailureEnvelope() throws Exception { + // GIVEN + loginTenantAdmin(); + SsrfProtectionValidator.setEnabled(true); + + try { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://10.0.0.1:8080/") + .apiKey("test-api-key") + .build()) + .modelId("gpt-4o") + .build(); + + var chatRequest = new TbChatRequest( + null, + new TbUserMessage(List.of(new TbContent.TbTextContent("hi"))), + modelConfig); + + // WHEN + TbChatResponse response = doPostAsync("/api/ai/model/chat", chatRequest, TbChatResponse.class, status().isOk()); + + // THEN + assertThat(response).isInstanceOf(TbChatResponse.Failure.class); + assertThat(((TbChatResponse.Failure) response).errorDetails()).contains("URI is invalid"); + } finally { + SsrfProtectionValidator.setEnabled(false); + } + } + /* --- Get by ID API tests --- */ @Test 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/DeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java index 3966bbb97a..0ee1a65cd8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; +import org.awaitility.Awaitility; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -52,8 +53,8 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.device.DeviceProfileDao; -import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.exception.DataValidationException; import java.util.ArrayList; import java.util.Arrays; @@ -62,6 +63,7 @@ import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -93,6 +95,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest { public DeviceProfileDao deviceProfileDao(DeviceProfileDao deviceProfileDao) { return Mockito.mock(DeviceProfileDao.class, AdditionalAnswers.delegatesTo(deviceProfileDao)); } + } @Before @@ -640,364 +643,364 @@ public class DeviceProfileControllerTest extends AbstractControllerTest { @Test public void testSaveProtoDeviceProfileWithInvalidProtoFile() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " required int32 parameter = 1;\n" + - "}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " required int32 parameter = 1;\n" + + "}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations"); } @Test public void testSaveProtoDeviceProfileWithInvalidProtoSyntax() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto2\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " required int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " required int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!"); } @Test public void testSaveProtoDeviceProfileOptionsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "option java_package = \"com.test.schemavalidation\";\n" + - "option java_multiple_files = true;\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!"); + "\n" + + "option java_package = \"com.test.schemavalidation\";\n" + + "option java_multiple_files = true;\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!"); } @Test public void testSaveProtoDeviceProfilePublicImportsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "import public \"oldschema.proto\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!"); + "\n" + + "import public \"oldschema.proto\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!"); } @Test public void testSaveProtoDeviceProfileImportsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "import \"oldschema.proto\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!"); + "\n" + + "import \"oldschema.proto\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!"); } @Test public void testSaveProtoDeviceProfileExtendDeclarationsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "extend google.protobuf.MethodOptions {\n" + - " MyMessage my_method_option = 50007;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "extend google.protobuf.MethodOptions {\n" + + " MyMessage my_method_option = 50007;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!"); } @Test public void testSaveProtoDeviceProfileEnumOptionsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "enum testEnum {\n" + - " option allow_alias = true;\n" + - " DEFAULT = 0;\n" + - " STARTED = 1;\n" + - " RUNNING = 2;\n" + - "}\n" + - "\n" + - "message testMessage {\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "enum testEnum {\n" + + " option allow_alias = true;\n" + + " DEFAULT = 0;\n" + + " STARTED = 1;\n" + + " RUNNING = 2;\n" + + "}\n" + + "\n" + + "message testMessage {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!"); } @Test public void testSaveProtoDeviceProfileNoOneMessageTypeExists() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "enum testEnum {\n" + - " DEFAULT = 0;\n" + - " STARTED = 1;\n" + - " RUNNING = 2;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "enum testEnum {\n" + + " DEFAULT = 0;\n" + + " STARTED = 1;\n" + + " RUNNING = 2;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!"); } @Test public void testSaveProtoDeviceProfileMessageTypeOptionsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message testMessage {\n" + - " option allow_alias = true;\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message testMessage {\n" + + " option allow_alias = true;\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!"); } @Test public void testSaveProtoDeviceProfileMessageTypeExtensionsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message TestMessage {\n" + - " extensions 100 to 199;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message TestMessage {\n" + + " extensions 100 to 199;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!"); } @Test public void testSaveProtoDeviceProfileMessageTypeReservedElementsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message Foo {\n" + - " reserved 2, 15, 9 to 11;\n" + - " reserved \"foo\", \"bar\";\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message Foo {\n" + + " reserved 2, 15, 9 to 11;\n" + + " reserved \"foo\", \"bar\";\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!"); } @Test public void testSaveProtoDeviceProfileMessageTypeGroupsElementsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message TestMessage {\n" + - " repeated group Result = 1 {\n" + - " optional string url = 2;\n" + - " optional string title = 3;\n" + - " repeated string snippets = 4;\n" + - " }\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message TestMessage {\n" + + " repeated group Result = 1 {\n" + + " optional string url = 2;\n" + + " optional string title = 3;\n" + + " repeated string snippets = 4;\n" + + " }\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!"); } @Test public void testSaveProtoDeviceProfileOneOfsGroupsElementsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SampleMessage {\n" + - " oneof test_oneof {\n" + - " string name = 1;\n" + - " group Result = 2 {\n" + - " \tstring url = 3;\n" + - " \tstring title = 4;\n" + - " \trepeated string snippets = 5;\n" + - " }\n" + - " }" + - "}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SampleMessage {\n" + + " oneof test_oneof {\n" + + " string name = 1;\n" + + " group Result = 2 {\n" + + " \tstring url = 3;\n" + + " \tstring title = 4;\n" + + " \trepeated string snippets = 5;\n" + + " }\n" + + " }" + + "}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!"); } @Test public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsField() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message PostTelemetry {\n" + - " int64 ts = 1;\n" + - " Values values = 2;\n" + - " \n" + - " message Values {\n" + - " string key1 = 3;\n" + - " bool key2 = 4;\n" + - " double key3 = 5;\n" + - " int32 key4 = 6;\n" + - " JsonObject key5 = 7;\n" + - " }\n" + - " \n" + - " message JsonObject {\n" + - " optional int32 someNumber = 8;\n" + - " repeated int32 someArray = 9;\n" + - " NestedJsonObject someNestedObject = 10;\n" + - " message NestedJsonObject {\n" + - " optional string key = 11;\n" + - " }\n" + - " }\n" + - "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message PostTelemetry {\n" + + " int64 ts = 1;\n" + + " Values values = 2;\n" + + " \n" + + " message Values {\n" + + " string key1 = 3;\n" + + " bool key2 = 4;\n" + + " double key3 = 5;\n" + + " int32 key4 = 6;\n" + + " JsonObject key5 = 7;\n" + + " }\n" + + " \n" + + " message JsonObject {\n" + + " optional int32 someNumber = 8;\n" + + " repeated int32 someArray = 9;\n" + + " NestedJsonObject someNestedObject = 10;\n" + + " message NestedJsonObject {\n" + + " optional string key = 11;\n" + + " }\n" + + " }\n" + + "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!"); } @Test public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsDateType() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message PostTelemetry {\n" + - " optional int32 ts = 1;\n" + - " Values values = 2;\n" + - " \n" + - " message Values {\n" + - " string key1 = 3;\n" + - " bool key2 = 4;\n" + - " double key3 = 5;\n" + - " int32 key4 = 6;\n" + - " JsonObject key5 = 7;\n" + - " }\n" + - " \n" + - " message JsonObject {\n" + - " optional int32 someNumber = 8;\n" + - " }\n" + - "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message PostTelemetry {\n" + + " optional int32 ts = 1;\n" + + " Values values = 2;\n" + + " \n" + + " message Values {\n" + + " string key1 = 3;\n" + + " bool key2 = 4;\n" + + " double key3 = 5;\n" + + " int32 key4 = 6;\n" + + " JsonObject key5 = 7;\n" + + " }\n" + + " \n" + + " message JsonObject {\n" + + " optional int32 someNumber = 8;\n" + + " }\n" + + "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!"); } @Test public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaValuesDateType() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message PostTelemetry {\n" + - " optional int64 ts = 1;\n" + - " string values = 2;\n" + - " \n" + - "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message PostTelemetry {\n" + + " optional int64 ts = 1;\n" + + " string values = 2;\n" + + " \n" + + "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodDateType() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional int32 method = 1;\n" + - " optional int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional int32 method = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdDateType() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " optional int64 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int64 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodLabel() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " repeated string method = 1;\n" + - " optional int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " repeated string method = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdLabel() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " repeated int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " repeated int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaParamsLabel() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " optional int32 requestId = 2;\n" + - " repeated string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int32 requestId = 2;\n" + + " repeated string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldsCount() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldMethodIsNoSet() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string methodName = 1;\n" + - " optional int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string methodName = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldRequestIdIsNotSet() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " optional int32 requestIdentifier = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int32 requestIdentifier = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldParamsIsNotSet() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " optional int32 requestId = 2;\n" + - " optional string parameters = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string parameters = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!"); } @Test @@ -1068,11 +1071,17 @@ public class DeviceProfileControllerTest extends AbstractControllerTest { MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false); DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration); - Mockito.reset(tbClusterService, auditLogService); - - doPost("/api/deviceProfile", deviceProfile) - .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString(errorMsg))); + // The request may hit a transient TenantNotFoundException right after the @Before tenant creation + // if the tenant profile cache is not yet warmed up for the newly created tenant. Retry until the + // request returns the expected 400 Bad Request for the invalid schema. Mockito.reset is inside the + // retry loop so the subsequent verify* assertions see only the invocations from the last attempt. + Awaitility.await().atMost(10, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS) + .ignoreExceptions().untilAsserted(() -> { + Mockito.reset(tbClusterService, auditLogService); + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(errorMsg))); + }); testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg)); diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 46ebf31f9d..5de4c9ffcf 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -1737,234 +1737,198 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // --- OR conditions tests --- - @Test - public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception { - // Create 3 devices with different temperature attributes - Device deviceA = new Device(); - deviceA.setName("OrTestDeviceA"); - deviceA.setType("orTestType"); - deviceA = doPost("/api/device", deviceA, Device.class); - String payloadA = "{\"temperature\":60}"; - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, payloadA, String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("OrTestDeviceB"); - deviceB.setType("orTestType"); - deviceB = doPost("/api/device", deviceB, Device.class); - String payloadB = "{\"temperature\":5}"; - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, payloadB, String.class, status().isOk()); - - Device deviceC = new Device(); - deviceC.setName("OrTestDeviceC"); - deviceC.setType("orTestType"); - deviceC = doPost("/api/device", deviceC, Device.class); - String payloadC = "{\"temperature\":30}"; - doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, payloadC, String.class, status().isOk()); + private Device createDeviceWithSharedAttributes(String name, String type, String sharedAttributesPayload) throws Exception { + Device device = new Device(); + device.setName(name); + device.setType(type); + device = doPost("/api/device", device, Device.class); + if (sharedAttributesPayload != null) { + doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, + sharedAttributesPayload, String.class, status().isOk()); + } + return device; + } + + private Device createDeviceWithTimeseries(String name, String type, String timeseriesPayload) throws Exception { + Device device = new Device(); + device.setName(name); + device.setType(type); + device = doPost("/api/device", device, Device.class); + JsonNode payload = JacksonUtil.toJsonNode(timeseriesPayload); + doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", payload) + .andExpect(status().isOk()); + return device; + } + + private Alarm createAlarm(DeviceId originator, String type, AlarmSeverity severity) throws Exception { + Alarm alarm = new Alarm(); + alarm.setOriginator(originator); + alarm.setType(type); + alarm.setSeverity(severity); + return doPost("/api/alarm", alarm, Alarm.class); + } + private static DeviceTypeFilter deviceTypeFilter(String type) { DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orTestType")); + filter.setDeviceTypes(List.of(type)); filter.setDeviceNameFilter(""); + return filter; + } - // Filter 1: temperature > 50 - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); + private static KeyFilter numericKeyFilter(EntityKeyType keyType, String key, + NumericFilterPredicate.NumericOperation operation, double value) { + KeyFilter keyFilter = new KeyFilter(); + keyFilter.setKey(new EntityKey(keyType, key)); + keyFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate predicate = new NumericFilterPredicate(); + predicate.setValue(FilterPredicateValue.fromDouble(value)); + predicate.setOperation(operation); + keyFilter.setPredicate(predicate); + return keyFilter; + } + + private static KeyFilter numericAttributeKeyFilter(String key, + NumericFilterPredicate.NumericOperation operation, double value) { + return numericKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value); + } + + private static KeyFilter stringKeyFilter(EntityKeyType keyType, String key, + StringFilterPredicate.StringOperation operation, String value) { + KeyFilter keyFilter = new KeyFilter(); + keyFilter.setKey(new EntityKey(keyType, key)); + keyFilter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate predicate = new StringFilterPredicate(); + predicate.setValue(FilterPredicateValue.fromString(value)); + predicate.setOperation(operation); + keyFilter.setPredicate(predicate); + return keyFilter; + } + + private static KeyFilter stringAttributeKeyFilter(String key, + StringFilterPredicate.StringOperation operation, String value) { + return stringKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value); + } + + private static EntityDataPageLink pageLinkSortedByName(int pageSize, int page, String textSearch) { + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC); + return new EntityDataPageLink(pageSize, page, textSearch, sortOrder); + } + + private static List nameEntityField() { + return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + } + + private static List extractNames(PageData result) { + return result.getData().stream() + .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()) + .collect(Collectors.toList()); + } - // Filter 2: temperature < 10 - KeyFilter tempLt10 = new KeyFilter(); - tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempLt10.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate lt10 = new NumericFilterPredicate(); - lt10.setValue(FilterPredicateValue.fromDouble(10)); - lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); - tempLt10.setPredicate(lt10); + @Test + public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception { + String type = "orTestType"; + createDeviceWithSharedAttributes("OrTestDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrTestDeviceB", type, "{\"temperature\":5}"); + createDeviceWithSharedAttributes("OrTestDeviceC", type, "{\"temperature\":30}"); - List keyFilters = List.of(tempGt50, tempLt10); + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); // OR: deviceA (60>50) and deviceB (5<10) match => count=2 - EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); // AND: no device has temperature both >50 AND <10 => count=0 - EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); countByQueryAndCheck(andQuery, 0); } @Test public void testFindEntityDataWithOrKeyFiltersOperation() throws Exception { - // Create devices with different attributes - Device deviceX = new Device(); - deviceX.setName("OrDataDeviceX"); - deviceX.setType("orDataType"); - deviceX = doPost("/api/device", deviceX, Device.class); - doPost("/api/plugins/telemetry/" + deviceX.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"status\":\"active\"}", String.class, status().isOk()); - - Device deviceY = new Device(); - deviceY.setName("OrDataDeviceY"); - deviceY.setType("orDataType"); - deviceY = doPost("/api/device", deviceY, Device.class); - doPost("/api/plugins/telemetry/" + deviceY.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"humidity\":80}", String.class, status().isOk()); - - Device deviceZ = new Device(); - deviceZ.setName("OrDataDeviceZ"); - deviceZ.setType("orDataType"); - deviceZ = doPost("/api/device", deviceZ, Device.class); - // deviceZ has neither matching attribute - - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orDataType")); - filter.setDeviceNameFilter(""); - - // Filter 1: status = "active" - KeyFilter statusFilter = new KeyFilter(); - statusFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "status")); - statusFilter.setValueType(EntityKeyValueType.STRING); - StringFilterPredicate statusPred = new StringFilterPredicate(); - statusPred.setValue(FilterPredicateValue.fromString("active")); - statusPred.setOperation(StringFilterPredicate.StringOperation.EQUAL); - statusFilter.setPredicate(statusPred); - - // Filter 2: humidity > 70 - KeyFilter humidityFilter = new KeyFilter(); - humidityFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "humidity")); - humidityFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate humidityPred = new NumericFilterPredicate(); - humidityPred.setValue(FilterPredicateValue.fromDouble(70)); - humidityPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - humidityFilter.setPredicate(humidityPred); - - List keyFilters = List.of(statusFilter, humidityFilter); + String type = "orDataType"; + createDeviceWithSharedAttributes("OrDataDeviceX", type, "{\"status\":\"active\"}"); + createDeviceWithSharedAttributes("OrDataDeviceY", type, "{\"humidity\":80}"); + createDeviceWithSharedAttributes("OrDataDeviceZ", type, null); // no matching attribute - EntityDataSortOrder sortOrder = new EntityDataSortOrder( - new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC - ); - EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); - List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + List keyFilters = List.of( + stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"), + numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 70)); // OR: deviceX matches status=active, deviceY matches humidity>70 - EntityDataQuery orQuery = new EntityDataQuery(filter, pageLink, entityFields, null, keyFilters, ComplexOperation.OR); + EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); PageData result = findByQueryAndCheck(orQuery, 2); - List names = result.getData().stream() - .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()) - .collect(Collectors.toList()); - assertThat(names).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY"); + assertThat(extractNames(result)).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY"); } @Test - public void testFindEntityDataWithOrSameKeyFilters() throws Exception { - // Create devices with temperature values that test same-key OR ungrouping - Device deviceA = new Device(); - deviceA.setName("OrSameKeyDeviceA"); - deviceA.setType("orSameKeyType"); - deviceA = doPost("/api/device", deviceA, Device.class); - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":60}", String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("OrSameKeyDeviceB"); - deviceB.setType("orSameKeyType"); - deviceB = doPost("/api/device", deviceB, Device.class); - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":5}", String.class, status().isOk()); - - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orSameKeyType")); - filter.setDeviceNameFilter(""); - - // Filter 1: temperature > 50 - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); - - // Filter 2: temperature < 10 (same key, different predicate) - KeyFilter tempLt10 = new KeyFilter(); - tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempLt10.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate lt10 = new NumericFilterPredicate(); - lt10.setValue(FilterPredicateValue.fromDouble(10)); - lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); - tempLt10.setPredicate(lt10); + public void testFindEntityDataWithOrDoesNotLeakFilterOnlyEntityFields() throws Exception { + // Regression test: under OR, an entity-field filter (e.g. label) that isn't declared in + // entityFields must not leak its value into EntityData.latest[ENTITY_FIELD]. + String type = "orLeakGuardType"; + Device d1 = createDeviceWithSharedAttributes("OrLeakDeviceA", type, "{\"status\":\"active\"}"); + d1.setLabel("leak-label-A"); + doPost("/api/device", d1, Device.class); + + Device d2 = createDeviceWithSharedAttributes("OrLeakDeviceB", type, null); + d2.setLabel("leak-label-B"); + doPost("/api/device", d2, Device.class); + + List keyFilters = List.of( + stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"), + buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "label", StringFilterPredicate.StringOperation.EQUAL, "leak-label-B")); + + EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); + PageData result = findByQueryAndCheck(orQuery, 2); + assertThat(extractNames(result)).containsExactlyInAnyOrder("OrLeakDeviceA", "OrLeakDeviceB"); + for (EntityData entity : result.getData()) { + assertThat(entity.getLatest().get(EntityKeyType.ENTITY_FIELD)).containsOnlyKeys("name"); + } + } - List keyFilters = List.of(tempGt50, tempLt10); + @Test + public void testFindEntityDataWithOrSameKeyFilters() throws Exception { + String type = "orSameKeyType"; + createDeviceWithSharedAttributes("OrSameKeyDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrSameKeyDeviceB", type, "{\"temperature\":5}"); - EntityDataSortOrder sortOrder = new EntityDataSortOrder( - new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC - ); - EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); - List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + // Same key "temperature" with two different predicates exercises the OR-ungrouping path + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); - // OR on same key: deviceA (60>50) and deviceB (5<10) should both be returned - EntityDataQuery orQuery = new EntityDataQuery(filter, pageLink, entityFields, null, keyFilters, ComplexOperation.OR); + EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); PageData result = findByQueryAndCheck(orQuery, 2); - List names = result.getData().stream() - .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()) - .collect(Collectors.toList()); - assertThat(names).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB"); + assertThat(extractNames(result)).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB"); } @Test public void testCountEntitiesWithoutKeyFiltersOperation() throws Exception { - // Create 2 devices with temperature attributes - Device deviceA = new Device(); - deviceA.setName("BackCompatDeviceA"); - deviceA.setType("backCompatType"); - deviceA = doPost("/api/device", deviceA, Device.class); - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":60}", String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("BackCompatDeviceB"); - deviceB.setType("backCompatType"); - deviceB = doPost("/api/device", deviceB, Device.class); - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":5}", String.class, status().isOk()); + String type = "backCompatType"; + createDeviceWithSharedAttributes("BackCompatDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("BackCompatDeviceB", type, "{\"temperature\":5}"); - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("backCompatType")); - filter.setDeviceNameFilter(""); - - // Filter 1: temperature > 50 - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); - - // Filter 2: temperature < 10 - KeyFilter tempLt10 = new KeyFilter(); - tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempLt10.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate lt10 = new NumericFilterPredicate(); - lt10.setValue(FilterPredicateValue.fromDouble(10)); - lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); - tempLt10.setPredicate(lt10); - - List keyFilters = List.of(tempGt50, tempLt10); + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); - // Await attribute propagation: verify with an OR query that should find 2 when propagated - EntityCountQuery orCheckQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + // Await attribute propagation via an OR query that should find 2 when propagated + EntityCountQuery orCheckQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orCheckQuery, 2)); // Query without keyFiltersOperation (null) -- should behave as AND - EntityCountQuery nullOpQuery = new EntityCountQuery(filter, keyFilters); + EntityCountQuery nullOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters); Long nullResult = countByQueryAndCheck(nullOpQuery, 0); // Query with explicit AND -- should produce the same result - EntityCountQuery andOpQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + EntityCountQuery andOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); Long andResult = countByQueryAndCheck(andOpQuery, 0); Assert.assertEquals(nullResult, andResult); @@ -1974,87 +1938,34 @@ public class EntityQueryControllerTest extends AbstractControllerTest { public void testAlarmDataQueryWithOrKeyFiltersOperation() throws Exception { loginTenantAdmin(); - // Create devices with different temperatures and alarms - Device deviceHot = new Device(); - deviceHot.setName("OrAlarmDeviceHot"); - deviceHot.setType("orAlarmType"); - deviceHot = doPost("/api/device", deviceHot, Device.class); - doPost("/api/plugins/telemetry/" + deviceHot.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":60}", String.class, status().isOk()); - - Device deviceCold = new Device(); - deviceCold.setName("OrAlarmDeviceCold"); - deviceCold.setType("orAlarmType"); - deviceCold = doPost("/api/device", deviceCold, Device.class); - doPost("/api/plugins/telemetry/" + deviceCold.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":5}", String.class, status().isOk()); - - Device deviceMid = new Device(); - deviceMid.setName("OrAlarmDeviceMid"); - deviceMid.setType("orAlarmType"); - deviceMid = doPost("/api/device", deviceMid, Device.class); - doPost("/api/plugins/telemetry/" + deviceMid.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":30}", String.class, status().isOk()); - - // Create alarms for each device - Alarm alarmHot = new Alarm(); - alarmHot.setOriginator(deviceHot.getId()); - alarmHot.setType("highTemp"); - alarmHot.setSeverity(AlarmSeverity.CRITICAL); - doPost("/api/alarm", alarmHot, Alarm.class); - - Alarm alarmCold = new Alarm(); - alarmCold.setOriginator(deviceCold.getId()); - alarmCold.setType("lowTemp"); - alarmCold.setSeverity(AlarmSeverity.WARNING); - doPost("/api/alarm", alarmCold, Alarm.class); - - Alarm alarmMid = new Alarm(); - alarmMid.setOriginator(deviceMid.getId()); - alarmMid.setType("normalTemp"); - alarmMid.setSeverity(AlarmSeverity.WARNING); - doPost("/api/alarm", alarmMid, Alarm.class); - - // Filter 1: temperature > 50 - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); - - // Filter 2: temperature < 10 - KeyFilter tempLt10 = new KeyFilter(); - tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempLt10.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate lt10 = new NumericFilterPredicate(); - lt10.setValue(FilterPredicateValue.fromDouble(10)); - lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); - tempLt10.setPredicate(lt10); + String type = "orAlarmType"; + Device deviceHot = createDeviceWithSharedAttributes("OrAlarmDeviceHot", type, "{\"temperature\":60}"); + Device deviceCold = createDeviceWithSharedAttributes("OrAlarmDeviceCold", type, "{\"temperature\":5}"); + Device deviceMid = createDeviceWithSharedAttributes("OrAlarmDeviceMid", type, "{\"temperature\":30}"); - List keyFilters = List.of(tempGt50, tempLt10); + createAlarm(deviceHot.getId(), "highTemp", AlarmSeverity.CRITICAL); + createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING); + createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING); - DeviceTypeFilter entityFilter = new DeviceTypeFilter(); - entityFilter.setDeviceTypes(List.of("orAlarmType")); - entityFilter.setDeviceNameFilter(""); + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); AlarmDataPageLink pageLink = new AlarmDataPageLink(); pageLink.setPage(0); pageLink.setPageSize(100); pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "createdTime"))); - List alarmFields = List.of(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); // OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms - AlarmDataQuery orAlarmQuery = new AlarmDataQuery(entityFilter, pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR); + AlarmDataQuery orAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findAlarmsByQueryAndCheck(orAlarmQuery, 2)); PageData alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2); List alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList()); assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp"); // AND query: no device has temp both >50 AND <10 => 0 alarms - AlarmDataQuery andAlarmQuery = new AlarmDataQuery(entityFilter, pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND); + AlarmDataQuery andAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND); findAlarmsByQueryAndCheck(andAlarmQuery, 0); } @@ -2062,232 +1973,86 @@ public class EntityQueryControllerTest extends AbstractControllerTest { public void testCountAlarmsByQueryWithOrKeyFiltersOperation() throws Exception { loginTenantAdmin(); - Device deviceHot = new Device(); - deviceHot.setName("OrAlarmCntDeviceHot"); - deviceHot.setType("orAlarmCntType"); - deviceHot = doPost("/api/device", deviceHot, Device.class); - doPost("/api/plugins/telemetry/" + deviceHot.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":60}", String.class, status().isOk()); - - Device deviceCold = new Device(); - deviceCold.setName("OrAlarmCntDeviceCold"); - deviceCold.setType("orAlarmCntType"); - deviceCold = doPost("/api/device", deviceCold, Device.class); - doPost("/api/plugins/telemetry/" + deviceCold.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":5}", String.class, status().isOk()); - - Device deviceMid = new Device(); - deviceMid.setName("OrAlarmCntDeviceMid"); - deviceMid.setType("orAlarmCntType"); - deviceMid = doPost("/api/device", deviceMid, Device.class); - doPost("/api/plugins/telemetry/" + deviceMid.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":30}", String.class, status().isOk()); - - // Create 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid - Alarm alarm1 = new Alarm(); - alarm1.setOriginator(deviceHot.getId()); - alarm1.setType("highTemp1"); - alarm1.setSeverity(AlarmSeverity.CRITICAL); - doPost("/api/alarm", alarm1, Alarm.class); - - Alarm alarm2 = new Alarm(); - alarm2.setOriginator(deviceHot.getId()); - alarm2.setType("highTemp2"); - alarm2.setSeverity(AlarmSeverity.CRITICAL); - doPost("/api/alarm", alarm2, Alarm.class); - - Alarm alarm3 = new Alarm(); - alarm3.setOriginator(deviceCold.getId()); - alarm3.setType("lowTemp"); - alarm3.setSeverity(AlarmSeverity.WARNING); - doPost("/api/alarm", alarm3, Alarm.class); - - Alarm alarm4 = new Alarm(); - alarm4.setOriginator(deviceMid.getId()); - alarm4.setType("normalTemp"); - alarm4.setSeverity(AlarmSeverity.WARNING); - doPost("/api/alarm", alarm4, Alarm.class); - - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); - - KeyFilter tempLt10 = new KeyFilter(); - tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempLt10.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate lt10 = new NumericFilterPredicate(); - lt10.setValue(FilterPredicateValue.fromDouble(10)); - lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); - tempLt10.setPredicate(lt10); + String type = "orAlarmCntType"; + Device deviceHot = createDeviceWithSharedAttributes("OrAlarmCntDeviceHot", type, "{\"temperature\":60}"); + Device deviceCold = createDeviceWithSharedAttributes("OrAlarmCntDeviceCold", type, "{\"temperature\":5}"); + Device deviceMid = createDeviceWithSharedAttributes("OrAlarmCntDeviceMid", type, "{\"temperature\":30}"); - List keyFilters = List.of(tempGt50, tempLt10); + // 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid + createAlarm(deviceHot.getId(), "highTemp1", AlarmSeverity.CRITICAL); + createAlarm(deviceHot.getId(), "highTemp2", AlarmSeverity.CRITICAL); + createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING); + createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING); - DeviceTypeFilter entityFilter = new DeviceTypeFilter(); - entityFilter.setDeviceTypes(List.of("orAlarmCntType")); - entityFilter.setDeviceNameFilter(""); + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); // OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total - AlarmCountQuery orQuery = new AlarmCountQuery(entityFilter, keyFilters, ComplexOperation.OR); + AlarmCountQuery orQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countAlarmsByQueryAndCheck(orQuery, 3)); // AND: no device matches both filters => 0 - AlarmCountQuery andQuery = new AlarmCountQuery(entityFilter, keyFilters, ComplexOperation.AND); + AlarmCountQuery andQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); countAlarmsByQueryAndCheck(andQuery, 0); } @Test public void testCountEntitiesWithOrMixedEntityFieldAndAttribute() throws Exception { - // Tests the entity field predicate relocation to middle-layer WHERE under OR - Device deviceA = new Device(); - deviceA.setName("OrMixedAlpha"); - deviceA.setType("orMixedType"); - deviceA = doPost("/api/device", deviceA, Device.class); - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":10}", String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("OrMixedBeta"); - deviceB.setType("orMixedType"); - deviceB = doPost("/api/device", deviceB, Device.class); - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":60}", String.class, status().isOk()); - - Device deviceC = new Device(); - deviceC.setName("OrMixedGamma"); - deviceC.setType("orMixedType"); - deviceC = doPost("/api/device", deviceC, Device.class); - doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":10}", String.class, status().isOk()); - - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orMixedType")); - filter.setDeviceNameFilter(""); - - // Filter 1: entity field name CONTAINS "Alpha" - KeyFilter nameFilter = new KeyFilter(); - nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); - nameFilter.setValueType(EntityKeyValueType.STRING); - StringFilterPredicate namePred = new StringFilterPredicate(); - namePred.setValue(FilterPredicateValue.fromString("Alpha")); - namePred.setOperation(StringFilterPredicate.StringOperation.CONTAINS); - nameFilter.setPredicate(namePred); - - // Filter 2: attribute temperature > 50 - KeyFilter tempFilter = new KeyFilter(); - tempFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate tempPred = new NumericFilterPredicate(); - tempPred.setValue(FilterPredicateValue.fromDouble(50)); - tempPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempFilter.setPredicate(tempPred); - - List keyFilters = List.of(nameFilter, tempFilter); - - // OR: deviceA matches name contains "Alpha", deviceB matches temp>50 => count=2 - EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + // Exercises the entity field predicate relocation to middle-layer WHERE under OR + String type = "orMixedType"; + createDeviceWithSharedAttributes("OrMixedAlpha", type, "{\"temperature\":10}"); + createDeviceWithSharedAttributes("OrMixedBeta", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrMixedGamma", type, "{\"temperature\":10}"); + + List keyFilters = List.of( + stringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.CONTAINS, "Alpha"), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50)); + + // OR: Alpha matches name contains "Alpha", Beta matches temp>50 => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); - // AND: only deviceA has name "Alpha" AND temp is 10 (not >50) => count=0 - EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + // AND: only Alpha has name "Alpha" AND temp is 10 (not >50) => count=0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); countByQueryAndCheck(andQuery, 0); } @Test public void testCountEntitiesWithOrStringAttributes() throws Exception { - Device deviceA = new Device(); - deviceA.setName("OrStrDeviceA"); - deviceA.setType("orStrType"); - deviceA = doPost("/api/device", deviceA, Device.class); - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"color\":\"red\"}", String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("OrStrDeviceB"); - deviceB.setType("orStrType"); - deviceB = doPost("/api/device", deviceB, Device.class); - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"color\":\"blue\"}", String.class, status().isOk()); - - Device deviceC = new Device(); - deviceC.setName("OrStrDeviceC"); - deviceC.setType("orStrType"); - deviceC = doPost("/api/device", deviceC, Device.class); - doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"color\":\"green\"}", String.class, status().isOk()); + String type = "orStrType"; + createDeviceWithSharedAttributes("OrStrDeviceA", type, "{\"color\":\"red\"}"); + createDeviceWithSharedAttributes("OrStrDeviceB", type, "{\"color\":\"blue\"}"); + createDeviceWithSharedAttributes("OrStrDeviceC", type, "{\"color\":\"green\"}"); - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orStrType")); - filter.setDeviceNameFilter(""); + List keyFilters = List.of( + stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "red"), + stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "blue")); - // Filter 1: color = "red" - KeyFilter redFilter = new KeyFilter(); - redFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "color")); - redFilter.setValueType(EntityKeyValueType.STRING); - StringFilterPredicate redPred = new StringFilterPredicate(); - redPred.setValue(FilterPredicateValue.fromString("red")); - redPred.setOperation(StringFilterPredicate.StringOperation.EQUAL); - redFilter.setPredicate(redPred); - - // Filter 2: color = "blue" - KeyFilter blueFilter = new KeyFilter(); - blueFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "color")); - blueFilter.setValueType(EntityKeyValueType.STRING); - StringFilterPredicate bluePred = new StringFilterPredicate(); - bluePred.setValue(FilterPredicateValue.fromString("blue")); - bluePred.setOperation(StringFilterPredicate.StringOperation.EQUAL); - blueFilter.setPredicate(bluePred); - - List keyFilters = List.of(redFilter, blueFilter); - - // OR: deviceA (red) and deviceB (blue) match => count=2 - EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + // OR: red and blue match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); // AND: no device is both red AND blue => count=0 - EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); countByQueryAndCheck(andQuery, 0); } @Test public void testCountEntitiesWithOrSingleFilter() throws Exception { - Device deviceA = new Device(); - deviceA.setName("OrSingleDeviceA"); - deviceA.setType("orSingleType"); - deviceA = doPost("/api/device", deviceA, Device.class); - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":60}", String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("OrSingleDeviceB"); - deviceB.setType("orSingleType"); - deviceB = doPost("/api/device", deviceB, Device.class); - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":30}", String.class, status().isOk()); - - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orSingleType")); - filter.setDeviceNameFilter(""); - - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); + String type = "orSingleType"; + createDeviceWithSharedAttributes("OrSingleDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrSingleDeviceB", type, "{\"temperature\":30}"); - List keyFilters = List.of(tempGt50); + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50)); // Single filter with OR should behave identically to AND - EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 1)); Long orResult = countByQueryAndCheck(orQuery, 1); - EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); Long andResult = countByQueryAndCheck(andQuery, 1); Assert.assertEquals(orResult, andResult); @@ -2295,193 +2060,80 @@ public class EntityQueryControllerTest extends AbstractControllerTest { @Test public void testCountEntitiesWithOrThreeFilters() throws Exception { - Device deviceA = new Device(); - deviceA.setName("Or3fDeviceA"); - deviceA.setType("or3fType"); - deviceA = doPost("/api/device", deviceA, Device.class); - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}", String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("Or3fDeviceB"); - deviceB.setType("or3fType"); - deviceB = doPost("/api/device", deviceB, Device.class); - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}", String.class, status().isOk()); - - Device deviceC = new Device(); - deviceC.setName("Or3fDeviceC"); - deviceC.setType("or3fType"); - deviceC = doPost("/api/device", deviceC, Device.class); - doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}", String.class, status().isOk()); - - Device deviceD = new Device(); - deviceD.setName("Or3fDeviceD"); - deviceD.setType("or3fType"); - deviceD = doPost("/api/device", deviceD, Device.class); - doPost("/api/plugins/telemetry/" + deviceD.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}", String.class, status().isOk()); + String type = "or3fType"; + createDeviceWithSharedAttributes("Or3fDeviceA", type, "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}"); + createDeviceWithSharedAttributes("Or3fDeviceB", type, "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}"); + createDeviceWithSharedAttributes("Or3fDeviceC", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}"); + createDeviceWithSharedAttributes("Or3fDeviceD", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}"); - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("or3fType")); - filter.setDeviceNameFilter(""); - - // Filter 1: temperature > 50 - KeyFilter tempFilter = new KeyFilter(); - tempFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate tempPred = new NumericFilterPredicate(); - tempPred.setValue(FilterPredicateValue.fromDouble(50)); - tempPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempFilter.setPredicate(tempPred); - - // Filter 2: humidity > 80 - KeyFilter humFilter = new KeyFilter(); - humFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "humidity")); - humFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate humPred = new NumericFilterPredicate(); - humPred.setValue(FilterPredicateValue.fromDouble(80)); - humPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - humFilter.setPredicate(humPred); - - // Filter 3: pressure > 1040 - KeyFilter pressFilter = new KeyFilter(); - pressFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "pressure")); - pressFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate pressPred = new NumericFilterPredicate(); - pressPred.setValue(FilterPredicateValue.fromDouble(1040)); - pressPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - pressFilter.setPredicate(pressPred); - - List keyFilters = List.of(tempFilter, humFilter, pressFilter); + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80), + numericAttributeKeyFilter("pressure", NumericFilterPredicate.NumericOperation.GREATER, 1040)); // OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3 - EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 3)); // AND: no device matches all three => 0 - EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); countByQueryAndCheck(andQuery, 0); } @Test public void testFindEntityDataWithOrPagination() throws Exception { - // Create 5 devices, 4 match OR filters (1,2: temp>50; 3,4: temp<10; 5: no match) - // Device 1: temp=61, Device 2: temp=62, Device 3: temp=2, Device 4: temp=1, Device 5: temp=25 - for (int i = 1; i <= 5; i++) { - Device device = new Device(); - device.setName(String.format("OrPageDevice%02d", i)); - device.setType("orPageType"); - device = doPost("/api/device", device, Device.class); - int temp = (i <= 2) ? 60 + i : (i <= 4) ? 5 - i : 25; - doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":" + temp + "}", String.class, status().isOk()); + // 5 devices, 4 match OR filters; explicit temperatures keep the setup easy to read. + String type = "orPageType"; + int[] temperatures = {61, 62, 2, 1, 25}; // devices 1,2: temp>50; 3,4: temp<10; 5: no match + for (int i = 0; i < temperatures.length; i++) { + createDeviceWithSharedAttributes( + String.format("OrPageDevice%02d", i + 1), type, + "{\"temperature\":" + temperatures[i] + "}"); } - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orPageType")); - filter.setDeviceNameFilter(""); - - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); - - KeyFilter tempLt10 = new KeyFilter(); - tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempLt10.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate lt10 = new NumericFilterPredicate(); - lt10.setValue(FilterPredicateValue.fromDouble(10)); - lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); - tempLt10.setPredicate(lt10); - - List keyFilters = List.of(tempGt50, tempLt10); - - EntityDataSortOrder sortOrder = new EntityDataSortOrder( - new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC - ); - List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); // Page 1: pageSize=2, totalElements=4, data.size()=2 - EntityDataPageLink pageLink1 = new EntityDataPageLink(2, 0, null, sortOrder); - EntityDataQuery orQuery1 = new EntityDataQuery(filter, pageLink1, entityFields, null, keyFilters, ComplexOperation.OR); + EntityDataQuery orQuery1 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery1, 4)); PageData page1 = findByQueryAndCheck(orQuery1, 4); Assert.assertEquals(2, page1.getData().size()); Assert.assertTrue(page1.hasNext()); // Page 2: remaining 2 of 4 - EntityDataPageLink pageLink2 = new EntityDataPageLink(2, 1, null, sortOrder); - EntityDataQuery orQuery2 = new EntityDataQuery(filter, pageLink2, entityFields, null, keyFilters, ComplexOperation.OR); + EntityDataQuery orQuery2 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 1, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); PageData page2 = findByQueryAndCheck(orQuery2, 4); Assert.assertEquals(2, page2.getData().size()); Assert.assertFalse(page2.hasNext()); - // All names across both pages should be from the 4 matching devices (01,02,03,04) - List allNames = new ArrayList<>(); - page1.getData().forEach(e -> allNames.add(e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())); - page2.getData().forEach(e -> allNames.add(e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())); - assertThat(allNames).hasSize(4); - assertThat(allNames).doesNotContain("OrPageDevice05"); + // All names across both pages should be from the 4 matching devices (01..04) + List allNames = new ArrayList<>(extractNames(page1)); + allNames.addAll(extractNames(page2)); + assertThat(allNames).hasSize(4).doesNotContain("OrPageDevice05"); } @Test public void testCountEntitiesWithOrZeroMatches() throws Exception { - Device deviceA = new Device(); - deviceA.setName("OrZeroDeviceA"); - deviceA.setType("orZeroType"); - deviceA = doPost("/api/device", deviceA, Device.class); - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":30}", String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("OrZeroDeviceB"); - deviceB.setType("orZeroType"); - deviceB = doPost("/api/device", deviceB, Device.class); - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":40}", String.class, status().isOk()); - - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orZeroType")); - filter.setDeviceNameFilter(""); - - // Await attribute propagation: verify devices are queryable by a filter that matches - KeyFilter tempGt20 = new KeyFilter(); - tempGt20.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt20.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt20 = new NumericFilterPredicate(); - gt20.setValue(FilterPredicateValue.fromDouble(20)); - gt20.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt20.setPredicate(gt20); - EntityCountQuery propagationCheck = new EntityCountQuery(filter, List.of(tempGt20), ComplexOperation.OR); + String type = "orZeroType"; + createDeviceWithSharedAttributes("OrZeroDeviceA", type, "{\"temperature\":30}"); + createDeviceWithSharedAttributes("OrZeroDeviceB", type, "{\"temperature\":40}"); + + // Await attribute propagation via a filter that actually matches both devices + EntityCountQuery propagationCheck = new EntityCountQuery(deviceTypeFilter(type), List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 20)), + ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(propagationCheck, 2)); - // Filter 1: temperature > 50 (no match) - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); - - // Filter 2: temperature < 10 (no match) - KeyFilter tempLt10 = new KeyFilter(); - tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempLt10.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate lt10 = new NumericFilterPredicate(); - lt10.setValue(FilterPredicateValue.fromDouble(10)); - lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); - tempLt10.setPredicate(lt10); - - List keyFilters = List.of(tempGt50, tempLt10); + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); // OR with no matches: neither filter matches any device => count=0 - EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); countByQueryAndCheck(orQuery, 0); } @@ -2491,9 +2143,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", false); try { - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("default")); - filter.setDeviceNameFilter(""); + DeviceTypeFilter filter = deviceTypeFilter("default"); // POST a query with OR operation -- should be rejected with 400 EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR); @@ -2516,172 +2166,66 @@ public class EntityQueryControllerTest extends AbstractControllerTest { @Test public void testFindEntityDataWithOrAndTextSearch() throws Exception { - // Create 3 devices: 2 match OR filters, but only 1 also matches textSearch - Device deviceA = new Device(); - deviceA.setName("OrTextAlpha"); - deviceA.setType("orTextType"); - deviceA = doPost("/api/device", deviceA, Device.class); - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":60}", String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("OrTextBeta"); - deviceB.setType("orTextType"); - deviceB = doPost("/api/device", deviceB, Device.class); - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":5}", String.class, status().isOk()); - - Device deviceC = new Device(); - deviceC.setName("OrTextGamma"); - deviceC.setType("orTextType"); - deviceC = doPost("/api/device", deviceC, Device.class); - doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":30}", String.class, status().isOk()); - - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orTextType")); - filter.setDeviceNameFilter(""); - - // Filter 1: temperature > 50 (matches deviceA) - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); - - // Filter 2: temperature < 10 (matches deviceB) - KeyFilter tempLt10 = new KeyFilter(); - tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); - tempLt10.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate lt10 = new NumericFilterPredicate(); - lt10.setValue(FilterPredicateValue.fromDouble(10)); - lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); - tempLt10.setPredicate(lt10); - - List keyFilters = List.of(tempGt50, tempLt10); + // 3 devices: 2 match OR filters, but only 1 also matches textSearch at a time + String type = "orTextType"; + createDeviceWithSharedAttributes("OrTextAlpha", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrTextBeta", type, "{\"temperature\":5}"); + createDeviceWithSharedAttributes("OrTextGamma", type, "{\"temperature\":30}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR without textSearch: Alpha and Beta match => 2 + EntityDataQuery orQueryNoText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2)); - EntityDataSortOrder sortOrder = new EntityDataSortOrder( - new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC - ); - List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + // OR with textSearch="Alpha": only Alpha matches both OR filter AND text search + EntityDataQuery orQueryWithText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Alpha"), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + assertThat(extractNames(findByQueryAndCheck(orQueryWithText, 1))).containsExactly("OrTextAlpha"); - // OR without textSearch: deviceA and deviceB match => 2 - EntityDataPageLink pageLinkNoText = new EntityDataPageLink(10, 0, null, sortOrder); - EntityDataQuery orQueryNoText = new EntityDataQuery(filter, pageLinkNoText, entityFields, null, keyFilters, ComplexOperation.OR); - await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2)); + // OR with textSearch="Beta": only Beta matches both OR filter AND text search + EntityDataQuery orQueryBeta = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Beta"), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + assertThat(extractNames(findByQueryAndCheck(orQueryBeta, 1))).containsExactly("OrTextBeta"); - // OR with textSearch="Alpha": only deviceA matches both OR filter AND text search - EntityDataPageLink pageLinkWithText = new EntityDataPageLink(10, 0, "Alpha", sortOrder); - EntityDataQuery orQueryWithText = new EntityDataQuery(filter, pageLinkWithText, entityFields, null, keyFilters, ComplexOperation.OR); - PageData result = findByQueryAndCheck(orQueryWithText, 1); - String name = result.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); - assertThat(name).isEqualTo("OrTextAlpha"); - - // OR with textSearch="Beta": only deviceB matches both OR filter AND text search - EntityDataPageLink pageLinkBeta = new EntityDataPageLink(10, 0, "Beta", sortOrder); - EntityDataQuery orQueryBeta = new EntityDataQuery(filter, pageLinkBeta, entityFields, null, keyFilters, ComplexOperation.OR); - PageData resultBeta = findByQueryAndCheck(orQueryBeta, 1); - String nameBeta = resultBeta.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); - assertThat(nameBeta).isEqualTo("OrTextBeta"); - - // OR with textSearch="Gamma": deviceC doesn't match any OR filter => 0 - EntityDataPageLink pageLinkGamma = new EntityDataPageLink(10, 0, "Gamma", sortOrder); - EntityDataQuery orQueryGamma = new EntityDataQuery(filter, pageLinkGamma, entityFields, null, keyFilters, ComplexOperation.OR); + // OR with textSearch="Gamma": Gamma doesn't match any OR filter => 0 + EntityDataQuery orQueryGamma = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Gamma"), + nameEntityField(), null, keyFilters, ComplexOperation.OR); findByQueryAndCheck(orQueryGamma, 0); } @Test public void testCountEntitiesWithOrTimeSeriesKeyFilters() throws Exception { - // Create devices and post time-series telemetry (not attributes) - Device deviceA = new Device(); - deviceA.setName("OrTsDeviceA"); - deviceA.setType("orTsType"); - deviceA = doPost("/api/device", deviceA, Device.class); - JsonNode tsPayloadA = JacksonUtil.toJsonNode("{\"temperature\": 60}"); - doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + deviceA.getUuidId() + "/timeseries/SERVER_SCOPE", tsPayloadA) - .andExpect(status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("OrTsDeviceB"); - deviceB.setType("orTsType"); - deviceB = doPost("/api/device", deviceB, Device.class); - JsonNode tsPayloadB = JacksonUtil.toJsonNode("{\"temperature\": 5}"); - doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + deviceB.getUuidId() + "/timeseries/SERVER_SCOPE", tsPayloadB) - .andExpect(status().isOk()); - - Device deviceC = new Device(); - deviceC.setName("OrTsDeviceC"); - deviceC.setType("orTsType"); - deviceC = doPost("/api/device", deviceC, Device.class); - JsonNode tsPayloadC = JacksonUtil.toJsonNode("{\"temperature\": 30}"); - doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + deviceC.getUuidId() + "/timeseries/SERVER_SCOPE", tsPayloadC) - .andExpect(status().isOk()); - - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orTsType")); - filter.setDeviceNameFilter(""); + String type = "orTsType"; + createDeviceWithTimeseries("OrTsDeviceA", type, "{\"temperature\":60}"); + createDeviceWithTimeseries("OrTsDeviceB", type, "{\"temperature\":5}"); + createDeviceWithTimeseries("OrTsDeviceC", type, "{\"temperature\":30}"); - // Filter 1: TIME_SERIES temperature > 50 - KeyFilter tempGt50 = new KeyFilter(); - tempGt50.setKey(new EntityKey(EntityKeyType.TIME_SERIES, "temperature")); - tempGt50.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate gt50 = new NumericFilterPredicate(); - gt50.setValue(FilterPredicateValue.fromDouble(50)); - gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - tempGt50.setPredicate(gt50); - - // Filter 2: TIME_SERIES temperature < 10 - KeyFilter tempLt10 = new KeyFilter(); - tempLt10.setKey(new EntityKey(EntityKeyType.TIME_SERIES, "temperature")); - tempLt10.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate lt10 = new NumericFilterPredicate(); - lt10.setValue(FilterPredicateValue.fromDouble(10)); - lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); - tempLt10.setPredicate(lt10); - - List keyFilters = List.of(tempGt50, tempLt10); + List keyFilters = List.of( + numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); // OR: deviceA (60>50) and deviceB (5<10) match => count=2 - EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); // AND: no device has ts temperature both >50 AND <10 => count=0 - EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); countByQueryAndCheck(andQuery, 0); } @Test public void testCountEntitiesWithOrComplexFilterPredicate() throws Exception { - // Tests key-level ComplexFilterPredicate combined with query-level OR - Device deviceA = new Device(); - deviceA.setName("OrCplxDeviceA"); - deviceA.setType("orCplxType"); - deviceA = doPost("/api/device", deviceA, Device.class); - doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":65,\"humidity\":50}", String.class, status().isOk()); - - Device deviceB = new Device(); - deviceB.setName("OrCplxDeviceB"); - deviceB.setType("orCplxType"); - deviceB = doPost("/api/device", deviceB, Device.class); - doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":25,\"humidity\":90}", String.class, status().isOk()); - - Device deviceC = new Device(); - deviceC.setName("OrCplxDeviceC"); - deviceC.setType("orCplxType"); - deviceC = doPost("/api/device", deviceC, Device.class); - doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, - "{\"temperature\":25,\"humidity\":50}", String.class, status().isOk()); + // Key-level ComplexFilterPredicate combined with query-level OR + String type = "orCplxType"; + createDeviceWithSharedAttributes("OrCplxDeviceA", type, "{\"temperature\":65,\"humidity\":50}"); + createDeviceWithSharedAttributes("OrCplxDeviceB", type, "{\"temperature\":25,\"humidity\":90}"); + createDeviceWithSharedAttributes("OrCplxDeviceC", type, "{\"temperature\":25,\"humidity\":50}"); - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("orCplxType")); - filter.setDeviceNameFilter(""); - - // Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) - // Matches deviceA (65) only + // Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) — matches A only NumericFilterPredicate gt50 = new NumericFilterPredicate(); gt50.setValue(FilterPredicateValue.fromDouble(50)); gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); @@ -2691,30 +2235,22 @@ public class EntityQueryControllerTest extends AbstractControllerTest { ComplexFilterPredicate complexTempPred = new ComplexFilterPredicate(); complexTempPred.setOperation(ComplexOperation.AND); complexTempPred.setPredicates(List.of(gt50, lt70)); - KeyFilter tempComplexFilter = new KeyFilter(); tempComplexFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); tempComplexFilter.setValueType(EntityKeyValueType.NUMERIC); tempComplexFilter.setPredicate(complexTempPred); - // Key filter 2: humidity > 80 (simple predicate) - // Matches deviceB (90) only - KeyFilter humFilter = new KeyFilter(); - humFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "humidity")); - humFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate humGt80 = new NumericFilterPredicate(); - humGt80.setValue(FilterPredicateValue.fromDouble(80)); - humGt80.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - humFilter.setPredicate(humGt80); - - List keyFilters = List.of(tempComplexFilter, humFilter); + // Key filter 2: humidity > 80 (simple predicate) — matches B only + List keyFilters = List.of( + tempComplexFilter, + numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80)); - // Query-level OR: deviceA matches key filter 1, deviceB matches key filter 2 => 2 - EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + // Query-level OR: A matches key filter 1, B matches key filter 2 => 2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); // Query-level AND: no device matches both key filters => 0 - EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); countByQueryAndCheck(andQuery, 0); } 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/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index f344335748..91dd0669b1 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -20,7 +20,6 @@ import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.test.web.servlet.ResultMatcher; import org.testcontainers.shaded.org.awaitility.Awaitility; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; @@ -224,7 +223,12 @@ public class UserEdgeTest extends AbstractEdgeTest { User savedUser = createUser(user, password); Assert.assertTrue(edgeImitator.waitForMessages()); Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); - Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); + // The initial USER ADDED edge event may bundle a UserCredentialsUpdateMsg when + // user activation completes before the event is processed, in addition to the 2 + // messages from the CREDENTIALS_UPDATED events fired during activation. Accept 2 or 3. + int credMsgCount = edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size(); + Assert.assertTrue("Expected 2 or 3 UserCredentialsUpdateMsg (ADDED/activation race), got " + credMsgCount, + credMsgCount == 2 || credMsgCount == 3); UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java new file mode 100644 index 0000000000..fb9807a2a8 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java @@ -0,0 +1,144 @@ +/** + * 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.ai; + +import com.google.cloud.vertexai.api.GenerationConfig; +import dev.langchain4j.model.chat.ChatModel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ResourceLock("SsrfProtectionValidator") +class Langchain4jChatModelConfigurerImplTest { + + private static final String TEST_SERVICE_ACCOUNT_KEY = """ + { + "type": "service_account", + "project_id": "test-project", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDNrHph/y7zyxIg\\ncmYYeOD8mFg9KraK71n84ffTQyVrl4HzlQgRIz5m4vM2rV5zjVLFi0xlAPT/iq/5\\nbh2zA4iXI0dEsR901nVjcL182t/GRYbKen53ZiuScBxBoCZPXW16Md+Yk8nMdNUb\\n4LoIRGZq64bjsJ+vh3Aa2gdGUHpDyebIXlXbF8ehWmEhgUsL7XjL0PkJ4lt2UMG+\\nx1j2Or25rqJmfc5M9kbxvINtdvSRTPiMOIXX00fCDZjQdd18RBVHOxraGxDgQpmv\\nk4qjFEPqGr0YTsa5dI8fz+4DqJpEi3rancRiTKM/KUwYLGnPSD07XqGfiDA8npVm\\nj4N62LhnAgMBAAECggEADbFfH87DRk7YQO8XgKdCOf7oglX+0NwjjmmlkVvwjgEI\\nZqT0ObPcz9u/MSjfV2vAEs/LK773ELD1NfLQqQjiBfHpfkIZOTLynhwKOYRBjqvf\\n+p38ynUzucGbV/vSC8meuW/AQPe3Nn9MFYQ4znEYrSNLbTWRRA3idvSEtHfffqDA\\nDHRBI1eIlxh1OTIR3L+HhcNYuus1LuoKnSlmwLGhAZLt7fjuWK3PkOiFT15e0M9M\\nUhp3WwhHcRC0o6bxT+BWRYKMVX3Vjlro4sF1fq4+jePThX1bpJcPfUmsC95tXPfX\\njfNGAxHlZ+MS1V/cLlIqyz7drXBcwCDJtbPmvavmNQKBgQD1ZR/ePHcjXUGM57U8\\nbxPatNOrcicvaP2AtTA6Y/JjfbcydkVXsenDGk0h6hykpIiMwrAaJuUTjGeM6QTI\\nOhK0k1QbGitcM71d9TSdLzWUdb3yvsyaPZlPR/6u2FBb6Bf9rWOQRYYyv1Lvu0+1\\nYLnR4sHBxiAur1NGxuHfA4ZUOwKBgQDWj+fcS/x/ifbCqexr7teWU+tUeyG3eLGA\\nMmB9eCkY7djl0/LHu/IHgqrGRVgra3IB1uI7Wr3jZYvlS7qGL3KpjeIPYj7LTQC5\\nznm0875NvJELPjQK/A4EM3mC057QRvb7y52KBNKJi7/JwHU7VHmudB78e7uGlW2K\\n5Ccl0PJFxQKBgCWv5yoJXT64JsYOG95xLLptBQkSmgQE+tHWgdal3Ob8urLsSRAD\\nyePl2Sy5OLbscfA0Qjlx+cJ70LdqXgqmKJNFASi8ZyZc59tTOkZdprvrLUXnmaKi\\njTYI14tgu06yIWUbSOwyUT7f9UvOF5rChSc/zQQGepDQ6lg3WR8X+nxbAoGBAKiu\\nfAcqSfjuuuuxcWgtXpoVoaZKI2i9Xza85DTf+ddabjHJXk3+iTm0VZQIwldoYjnl\\n+PfW0ABtPf1net2xgcChBf84Ksvj3tU06WQEWDF/NLyVC48zN8W/viDHREzT7app\\nGpJ+VhLCpmXzg3bAY+Vt70pp8DTPV05hLhHB4iZNAoGBAJW+bYh7jE61+58VpjvF\\nP36BK09jEEPWVucJdghb2mb62iA6JDy3ApU+8FzckXHDewt0sqvsW4VqukgwVZx3\\npSC7mR4B+Fm6znm0Z5mBWiG5bOOgTJ0mRZv4cYgC+JRRF/E3yYR58RyAKFAFIAFH\\nng8XYP1wQp64Fzv4+rUSwM49\\n-----END PRIVATE KEY-----\\n", + "client_email": "test@test-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } + """; + + private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); + + @BeforeEach + void enableSsrfProtection() { + SsrfProtectionValidator.setEnabled(true); + } + + @AfterEach + void disableSsrfProtection() { + SsrfProtectionValidator.setEnabled(false); + } + + @Test + void configureChatModel_openAi_withPrivateIp_shouldThrow() { + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://172.17.0.1:8080/") + .apiKey("test") + .build()) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_openAi_withLocalhostUrl_shouldThrow() { + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://localhost:22/") + .apiKey("test") + .build()) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() { + var config = AzureOpenAiChatModelConfig.builder() + .providerConfig(new AzureOpenAiProviderConfig( + "http://10.0.0.1:8080/", null, "test-key")) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_ollama_withPrivateIp_shouldThrow() { + var config = OllamaChatModelConfig.builder() + .providerConfig(new OllamaProviderConfig( + "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None())) + .modelId("llama3") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() { + // GIVEN + var providerConfig = new GoogleVertexAiGeminiProviderConfig( + "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY + ); + var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder() + .providerConfig(providerConfig) + .modelId("gemini-2.0-flash") + .frequencyPenalty(0.3) + .presencePenalty(0.7) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(chatModelConfig); + + // THEN + var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig"); + assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f); + assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f); + } + +} 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/entitiy/EdqsEntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java index adf664e7cf..eb672a790f 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java @@ -16,19 +16,27 @@ package org.thingsboard.server.service.entitiy; import com.google.common.collect.Lists; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.IdBased; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.relation.EntitySearchDirection; @@ -43,10 +51,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; @DaoSqlTest @TestPropertySource(properties = { @@ -103,6 +114,100 @@ public class EdqsEntityServiceTest extends EntityServiceTest { assetService.deleteAssetsByTenantId(tenantId); } + // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC + @Override + @Test + public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(1L, 0L, 0L); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test")); + + EntityDataSortOrder ascSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC); + EntityDataQuery ascQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null); + List ascTelemetry = loadAllData(ascQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "0", "0", "1")); + + EntityDataSortOrder descSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC); + EntityDataQuery descQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null); + List descTelemetry = loadAllData(descQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(descTelemetry).containsExactlyElementsOf(List.of("1", "0", "0", "", "")); + } + + // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC + @Override + @Test + public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(true, false, false); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test")); + + EntityDataSortOrder ascSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC); + EntityDataQuery ascQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null); + List ascTelemetry = loadAllData(ascQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "false", "false", "true")); + + EntityDataSortOrder descSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC); + EntityDataQuery descQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null); + List descTelemetry = loadAllData(descQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(descTelemetry).containsExactlyElementsOf(List.of("true", "false", "false", "", "")); + } + @Override protected PageData findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) { return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query), diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index 05ab1165f6..b3230b3d45 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java @@ -26,6 +26,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; @@ -47,6 +48,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; @@ -79,7 +81,6 @@ import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.data.query.StringFilterPredicate.StringOperation; -import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; @@ -100,6 +101,7 @@ import org.thingsboard.server.dao.entityview.EntityViewDao; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.sql.query.DefaultEntityQueryRepository; import org.thingsboard.server.dao.sql.relation.RelationRepository; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; @@ -124,7 +126,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE; import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE; import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; -import static org.thingsboard.server.common.data.query.EntityKeyType.SERVER_ATTRIBUTE; @Slf4j @DaoSqlTest @@ -154,6 +155,8 @@ public class EntityServiceTest extends AbstractControllerTest { @Autowired RelationRepository relationRepository; @Autowired + DefaultEntityQueryRepository entityQueryRepository; + @Autowired RelationService relationService; @Autowired TimeseriesService timeseriesService; @@ -1750,6 +1753,117 @@ public class EntityServiceTest extends AbstractControllerTest { deviceService.deleteDevicesByTenantId(tenantId); } + @Test + public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + try { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(1L, 0L, 0L); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + assertNullsOrdering("default", + List.of("0", "0", "1", "", ""), + List.of("", "", "1", "0", "0"), + devices.size()); + + assertNullsOrdering("nulls_first", + List.of("", "", "0", "0", "1"), + List.of("", "", "1", "0", "0"), + devices.size()); + + assertNullsOrdering("nulls_last", + List.of("0", "0", "1", "", ""), + List.of("1", "0", "0", "", ""), + devices.size()); + } finally { + deviceService.deleteDevicesByTenantId(tenantId); + } + } + + @Test + public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + try { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(true, false, false); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + assertNullsOrdering("default", + List.of("false", "false", "true", "", ""), + List.of("", "", "true", "false", "false"), + devices.size()); + + assertNullsOrdering("nulls_first", + List.of("", "", "false", "false", "true"), + List.of("", "", "true", "false", "false"), + devices.size()); + + assertNullsOrdering("nulls_last", + List.of("false", "false", "true", "", ""), + List.of("true", "false", "false", "", ""), + devices.size()); + } finally { + deviceService.deleteDevicesByTenantId(tenantId); + } + } + + private void assertNullsOrdering(String strategy, List expectedAsc, List expectedDesc, int deviceSize) { + String originalStrategy = entityQueryRepository.getNullsOrderStrategy(); + ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", strategy); + try { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test")); + + EntityDataSortOrder ascSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC); + EntityDataQuery ascQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null); + List ascTelemetry = loadAllData(ascQuery, deviceSize).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(ascTelemetry).as("ASC with strategy '%s'", strategy).containsExactlyElementsOf(expectedAsc); + + EntityDataSortOrder descSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC); + EntityDataQuery descQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null); + List descTelemetry = loadAllData(descQuery, deviceSize).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(descTelemetry).as("DESC with strategy '%s'", strategy).containsExactlyElementsOf(expectedDesc); + } finally { + ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", originalStrategy); + } + } + @Test public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException { // save timeseries by sys admin @@ -2321,12 +2435,18 @@ public class EntityServiceTest extends AbstractControllerTest { return timeseriesService.save(tenantId, entityId, timeseries); } - private ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) { + protected ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) { KvEntry telemetryValue = new LongDataEntry(key, value); BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); return timeseriesService.save(tenantId, entityId, timeseries); } + protected ListenableFuture saveTimeseries(EntityId entityId, String key, Boolean value) { + KvEntry telemetryValue = new BooleanDataEntry(key, value); + BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); + return timeseriesService.save(tenantId, entityId, timeseries); + } + protected void createMultiRootHierarchy(List buildings, List apartments, Map> entityNameByTypeMap, Map childParentRelationMap) throws InterruptedException { 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/script/TbelInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java index 54e1a39769..8618e515c8 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java @@ -217,6 +217,90 @@ class TbelInvokeServiceTest extends AbstractTbelInvokeTest { assertThat(compiledScriptsCache.getIfPresent(scriptIdToHash.get(scriptRemovedFromCache))).isNotNull(); } + @Test + void givenForbiddenSocketHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.logging.SocketHandler(\"127.0.0.1\", 9999)"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.SocketHandler"); + }); + } + + @Test + void givenForbiddenZipFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.zip.ZipFile(\"/tmp/test.zip\")"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.zip.ZipFile"); + }); + } + + @Test + void givenForbiddenFileHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.logging.FileHandler(\"/tmp/test.log\")"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.FileHandler"); + }); + } + + @Test + void givenForbiddenJarFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.jar.JarFile(\"/tmp/test.jar\")"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.jar.JarFile"); + }); + } + + @Test + void givenForbiddenPreferences_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("java.util.prefs.Preferences.userRoot()"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getMessage()).contains("unresolvable property or identifier: java"); + }); + } + + @Test + void givenForbiddenLocaleServiceProvider_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.spi.LocaleServiceProvider()"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.spi.LocaleServiceProvider"); + }); + } + private void assertThatScriptIsBlocked(UUID scriptId) { assertThatThrownBy(() -> { invokeScriptResultString(scriptId, "{}"); 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..f2485dc7d5 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -31,13 +31,21 @@ import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; import org.thingsboard.server.service.system.SystemPatchApplier; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -71,9 +79,18 @@ public class SystemPatchApplierTest { @Mock private InstallScripts installScripts; + @Mock + private DatabaseSchemaSettingsService schemaSettingsService; + @Mock private WidgetTypeService widgetTypeService; + @Mock + private WidgetsBundleService widgetsBundleService; + + @Mock + private ImageService imageService; + @InjectMocks private SystemPatchApplier reconciler; @@ -145,19 +162,72 @@ public class SystemPatchApplierTest { } @Test - void whenWidgetNotFound_thenThrowException() throws Exception { + void whenWidgetNotFound_thenCreateNewWidget() throws Exception { Path widgetTypesDir = tempDir.resolve("widget_types"); Files.createDirectories(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); - WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget"); - String json = JacksonUtil.toString(testWidget); + WidgetTypeDetails fileWidget = createTestWidgetType("new_widget", "New Widget"); + String json = JacksonUtil.toString(fileWidget); assertNotNull(json); - Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + Files.writeString(widgetTypesDir.resolve("new_widget.json"), json); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "new_widget")).thenReturn(null); + + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertNotNull(stats); + assertEquals(1, stats.created()); + assertEquals(0, stats.updated()); + verify(widgetTypeService).saveWidgetType(argThat(w -> "new_widget".equals(w.getFqn()))); + } + + @Test + void whenFqnIsBlank_thenThrowException() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); - when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null); + WidgetTypeDetails brokenWidget = createTestWidgetType("", "Broken Widget"); + String json = JacksonUtil.toString(brokenWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("broken.json"), json); assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes")); + verify(widgetTypeService, never()).saveWidgetType(any()); + } + + @Test + void whenMixOfCreatedAndUpdated_thenStatsAreCorrect() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails newFileWidget = createTestWidgetType("widget_new", "Widget New"); + Files.writeString(widgetTypesDir.resolve("widget_new.json"), JacksonUtil.toString(newFileWidget)); + + WidgetTypeDetails changedFileWidget = createTestWidgetType("widget_changed", "Widget Changed New Name"); + Files.writeString(widgetTypesDir.resolve("widget_changed.json"), JacksonUtil.toString(changedFileWidget)); + + WidgetTypeDetails sameFileWidget = createTestWidgetType("widget_same", "Widget Same"); + Files.writeString(widgetTypesDir.resolve("widget_same.json"), JacksonUtil.toString(sameFileWidget)); + + WidgetTypeDetails existingChanged = createTestWidgetType("widget_changed", "Widget Changed Old Name"); + existingChanged.setId(new WidgetTypeId(UUID.randomUUID())); + + WidgetTypeDetails existingSame = createTestWidgetType("widget_same", "Widget Same"); + existingSame.setId(new WidgetTypeId(UUID.randomUUID())); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_new")).thenReturn(null); + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_changed")).thenReturn(existingChanged); + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_same")).thenReturn(existingSame); + + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertNotNull(stats); + assertEquals(1, stats.created()); + assertEquals(1, stats.updated()); + verify(widgetTypeService, times(2)).saveWidgetType(any()); } @Test @@ -179,9 +249,11 @@ public class SystemPatchApplierTest { when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) .thenReturn(existingWidget); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - assertEquals(1, updated); + assertNotNull(stats); + assertEquals(0, stats.created()); + assertEquals(1, stats.updated()); verify(widgetTypeService).saveWidgetType(argThat(w -> w.getDescriptor().get("version").asInt() == 2 )); @@ -204,9 +276,11 @@ public class SystemPatchApplierTest { when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) .thenReturn(existingWidget); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - assertEquals(1, updated); + assertNotNull(stats); + assertEquals(0, stats.created()); + assertEquals(1, stats.updated()); verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName()))); } @@ -227,9 +301,11 @@ public class SystemPatchApplierTest { when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) .thenReturn(existingWidget); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - assertEquals(0, updated); + assertNotNull(stats); + assertEquals(0, stats.created()); + assertEquals(0, stats.updated()); verify(widgetTypeService, never()).saveWidgetType(any()); } @@ -329,8 +405,8 @@ public class SystemPatchApplierTest { // Simulate work while holding lock Thread.sleep(100); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - firstThreadSavedWidget.set(updated != null && updated > 0); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + firstThreadSavedWidget.set(stats != null && stats.updated() > 0); ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); } @@ -350,8 +426,8 @@ public class SystemPatchApplierTest { secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); if (secondThreadAcquiredLock.get()) { - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - secondThreadSavedWidget.set(updated != null && updated > 0); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + secondThreadSavedWidget.set(stats != null && stats.updated() > 0); ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); } @@ -373,6 +449,249 @@ 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); + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing")); + + 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); + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing")); + + 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), @@ -407,4 +726,398 @@ public class SystemPatchApplierTest { return widget; } + // --- createMissingSystemImages tests --- + + @Test + void whenImagesDirDoesNotExist_thenReturnsZeroAndDoesNotCallImageService() { + Path dataDir = tempDir.resolve("data"); + // Intentionally do not create resources/images dir + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(0, created); + verify(imageService, never()).getAllImageKeysByTenantId(any()); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + @Test + void whenImagesDirIsEmpty_thenReturnsZeroAndDoesNotCallImageService() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(0, created); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + @Test + void whenSystemImageDoesNotExistInDb_thenCreateIt() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + byte[] imageBytes = new byte[]{1, 2, 3, 4, 5}; + Files.write(imagesDir.resolve("gateway.png"), imageBytes); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(1, created); + verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService).createOrUpdateSystemImage(eq("gateway.png"), eq(imageBytes)); + } + + @Test + void whenSystemImageExistsInDb_thenSkipIt() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("gateway.png"), new byte[]{1, 2, 3}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("gateway.png")); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(0, created); + verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + @Test + void whenMixOfNewAndExistingImages_thenOnlyCreateMissingOnes() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + byte[] newImageBytes = new byte[]{9, 9, 9}; + byte[] existingImageBytes = new byte[]{1, 1, 1}; + Files.write(imagesDir.resolve("new.png"), newImageBytes); + Files.write(imagesDir.resolve("existing.svg"), existingImageBytes); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("existing.svg")); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(1, created); + verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService).createOrUpdateSystemImage(eq("new.png"), eq(newImageBytes)); + verify(imageService, never()).createOrUpdateSystemImage(eq("existing.svg"), any(byte[].class)); + } + + @Test + void whenImagesDirContainsSubdirectory_thenSubdirectoryIsIgnored() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + Files.createDirectories(imagesDir.resolve("nested")); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + byte[] imageBytes = new byte[]{5, 6, 7}; + Files.write(imagesDir.resolve("logo.png"), imageBytes); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(1, created); + verify(imageService).createOrUpdateSystemImage(eq("logo.png"), eq(imageBytes)); + verify(imageService, never()).createOrUpdateSystemImage(eq("nested"), any(byte[].class)); + } + + @Test + void whenMultipleNewImages_thenCreatesAll() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("a.png"), new byte[]{1}); + Files.write(imagesDir.resolve("b.svg"), new byte[]{2}); + Files.write(imagesDir.resolve("c.jpg"), new byte[]{3}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(3, created); + verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService).createOrUpdateSystemImage(eq("a.png"), any(byte[].class)); + verify(imageService).createOrUpdateSystemImage(eq("b.svg"), any(byte[].class)); + verify(imageService).createOrUpdateSystemImage(eq("c.jpg"), any(byte[].class)); + } + + @Test + void whenImageServiceThrows_thenWrapsAndPropagates() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("broken.png"), new byte[]{1, 2}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + when(imageService.createOrUpdateSystemImage(eq("broken.png"), any(byte[].class))) + .thenThrow(new RuntimeException("DB error")); + + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages")); + assertTrue(thrown.getMessage().contains("broken.png")); + } + + @Test + void whenExistingKeysLookupFails_thenDoesNotCreateImage() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("img.png"), new byte[]{1}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)) + .thenThrow(new RuntimeException("lookup failed")); + + assertThrows(RuntimeException.class, + () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages")); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + // --- applyPatchIfNeeded integration with createMissingSystemImages --- + + @Test + void whenApplyPatchIfNeededRuns_thenCreatesMissingImagesAfterWidgets() 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 imagesDir = dataDir.resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + byte[] imgBytes = new byte[]{7, 7, 7}; + Files.write(imagesDir.resolve("new_icon.svg"), imgBytes); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing")); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(imageService).createOrUpdateSystemImage(eq("new_icon.svg"), eq(imgBytes)); + verify(schemaSettingsService).updateSchemaVersion(); + } + + @Test + void whenVersionNotIncreased_thenImagesAreNotTouched() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(imageService, never()).getAllImageKeysByTenantId(any()); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + // --- updateWidgetBundles tests --- + + @Test + void whenWidgetBundlesDirDoesNotExist_thenReturnsZero() { + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("missing_bundles")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleNotInDb_thenSkipWithoutCreation() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"order\":10}," + + "\"widgetTypeFqns\":[\"line_chart\"]}"); + + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(null); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleExistsAndHasNewFqn_thenMergeFqns() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," + + "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\",\"new_chart\"]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of("line_chart", "bar_chart")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(1, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService).updateWidgetsBundleWidgetFqns( + eq(TenantId.SYS_TENANT_ID), + eq(existingBundle.getId()), + argThat(fqns -> fqns.size() == 3 + && fqns.get(0).equals("line_chart") + && fqns.get(1).equals("bar_chart") + && fqns.get(2).equals("new_chart")) + ); + } + + @Test + void whenBundleExistsAndAllFqnsAlreadyLinked_thenNoLinkUpdate() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," + + "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\"]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of("line_chart", "bar_chart")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenOnlyBundleImageFormatDiffers_thenNoUpdate() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + // File carries a base64 data URI; DB has the resolved system-image URL — same content, different format. + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10," + + "\"image\":\"data:image/png;base64,iVBORw0KGgo\"}," + + "\"widgetTypeFqns\":[]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + existingBundle.setImage("tb-image;/api/images/system/charts.png"); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of()); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleMetadataChanged_thenUpdateBundle() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"New Title\",\"description\":\"new\",\"order\":20}," + + "\"widgetTypeFqns\":[\"line_chart\"]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Old Title"); + existingBundle.setDescription("old"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of("line_chart")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(1, updated); + verify(widgetsBundleService).saveWidgetsBundle(argThat(b -> + "New Title".equals(b.getTitle()) && "new".equals(b.getDescription()) && b.getOrder() == 20 + )); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleAliasIsBlank_thenThrowException() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("broken.json"), + "{\"widgetsBundle\":{\"alias\":\"\",\"title\":\"Broken\"}}"); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles")); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + } + + @Test + void whenBundleJsonMissingWidgetsBundleField_thenThrowException() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("broken.json"), "{\"foo\":\"bar\"}"); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles")); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + } + + @Test + void whenBundleHasInlineWidgetTypes_thenThrowException() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," + + "\"widgetTypes\":[" + + "{\"fqn\":\"inline_chart\",\"name\":\"Inline\",\"descriptor\":{\"type\":\"latest\"}}" + + "]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles")); + verify(widgetTypeService, never()).saveWidgetType(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + } + + private WidgetsBundle createTestBundle(String alias, String title) { + WidgetsBundle bundle = new WidgetsBundle(); + bundle.setId(new WidgetsBundleId(UUID.randomUUID())); + bundle.setAlias(alias); + bundle.setTitle(title); + bundle.setTenantId(TenantId.SYS_TENANT_ID); + return bundle; + } + } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java index 191bc97579..7d4c4b75e5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java @@ -160,7 +160,13 @@ public class SimpleLwM2MDevice extends BaseInstanceEnabler implements Destroyabl if (!arguments.isEmpty()) withArguments = " with arguments " + arguments; log.info("Execute on Device resource /{}/{}/{} {}", getModel().id, getId(), resourceId, withArguments); - return ExecuteResponse.success(); + switch (resourceId) { + case 4: + case 5: + return ExecuteResponse.success(); + default: + return super.execute(identity, resourceId, arguments); + } } @Override diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java index f5b4538c1c..92e25a793e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java @@ -29,6 +29,7 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INST import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_3; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_4; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_5; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_8; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; @@ -90,26 +91,101 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT /** - * execute_resource_with_parameters (execute reboot after 60 seconds on device) - * Execute {"id":"3/0/4","value":60} + * execute_resource_with_parameters (execute reboot if digit = 5 on device) + * Execute {"id":"3/0/4","value":5} * {"result":"CHANGED"} */ @Test - public void testExecuteResourceWithParametersById_Result_CHANGED() throws Exception { + public void testExecuteResourceWithParametersSingleDigitValueById_Result_Ok() throws Exception { String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_4; - Object expectedValue = 60; + Object expectedValue = 5; String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); } + /** + * execute_resource_with_parameters (execute Factory Reset if digit = 2 -> after 60 seconds on device) + * Execute {"id":"3/0/5","value":"2='60'"} + + */ + @Test + public void testExecuteResourceWithParametersArgumentIdAndValueById_Result_Ok() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "2='60'"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + } + + /** + * execute_resource_with_parameters (execute Factory Reset with two arguments: + * digit 2 without a value and digit 0 with the link value on device) + * Execute {"id":"3/0/5","value":"2,0='https://thingsboard.io/docs/reference/lwm2m-api/'"} + */ + @Test + public void testExecuteResourceWithParametersMultipleArgumentsIncludingLinkById_Result_Ok() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "2,0='https://thingsboard.io/docs/reference/lwm2m-api/'"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + } + + /** + * execute_resource_with_parameters (execute Factory Reset with multiple arguments without values) + * According to the OMA LwM2M execute arguments format, this represents ten arguments (digits 0-9), all without values. + * Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9"} + */ + @Test + public void testExecuteResourceWithParametersMultipleArgumentsById_Result_Ok() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "0,1,2,3,4,5,6,7,8,9"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + } + + + /** + * execute_resource_with_parameters (execute Factory Reset after 60 seconds on device) + * Execute {"id":"3/0/5","value":"'60'"} + */ + @Test + public void testExecuteResourceWithParametersSingleDigitValueInvalidById_Result_BAD_REQUEST_Error_IntegerBetween_0_And_9_Expected() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "'60'"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText()); + String expected = "Unable to parse Arguments [" + expectedValue + "] : Invalid digit ['] (an integer between 0 and 9 is expected)"; + String actual = rpcActualResult.get("error").asText(); + assertTrue(actual.contains(expected)); + } + + /** + * execute_resource_with_parameters (execute Bad with Unable to parse Arguments) + * Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9,60"} + */ + @Test + public void testExecuteResourceWithParametersMultipleArgumentsById_Result_BAD_REQUEST_Error_UnableParseArguments() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "0,1,2,3,4,5,6,7,8,9,60"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);; + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText()); + String expected = "Unable to parse Arguments [" + expectedValue + "] : [,] separator expected at index 21 after [0,1,2,3,4,5,6,7,8,9,6]"; + String actual = rpcActualResult.get("error").asText(); + assertTrue(actual.contains(expected)); + } + /** * Bootstrap-Request Trigger * Execute {"id":"1/0/9"} * {"result":"BAD_REQUEST","error":"probably no bootstrap server configured"} */ @Test - public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServerConfigured() throws Exception { + public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServer() throws Exception { String expectedPath = objectInstanceIdVer_1 + "/" + RESOURCE_ID_9; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -125,7 +201,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT * {"result":"BAD_REQUEST","error":"Resource with /5_1.0/0/3 is not executable."} */ @Test - public void testExecuteResourceWithOperationNotExecuteById_Result_METHOD_NOT_ALLOWED() throws Exception { + public void testExecuteResourceWithOperationNotExecuteById_Result_BAD_REQUEST_Error_Is_Not_Executable() throws Exception { String expectedPath = objectInstanceIdVer_5 + "/" + RESOURCE_ID_3; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -141,7 +217,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT * {"result":"BAD_REQUEST","error":"Specified object id 50 absent in the list supported objects of the client or is security object!"} */ @Test - public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST() throws Exception { + public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST_Error_Specified_Object_Absent_List_Supported() throws Exception { String expectedPath = OBJECT_ID_VER_50 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -159,7 +235,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT * {"result":"BAD_REQUEST","error":"Specified object id 0 absent in the list supported objects of the client or is security object!"} */ @Test - public void testExecuteSecurityObjectById_Result_NOT_FOUND() throws Exception { + public void testExecuteSecurityObjectById_Result_BAD_REQUEST_Error_SpecifiedObjectAbsent() throws Exception { String expectedPath = objectIdVer_0 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -178,8 +254,26 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT } private String sendRPCExecuteWithValueById(String path, Object value) throws Exception { - String setRpcRequest = "{\"method\": \"Execute\", \"params\": {\"id\": \"" + path + "\", \"value\": " + value + " }}"; - return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); + ObjectNode params = JacksonUtil.newObjectNode(); + params.put("id", path); + + // Jackson сам вирішить: ставити лапки (рядок) чи ні (число/boolean/null) + if (value instanceof String) { + params.put("value", (String) value); + } else if (value instanceof Integer) { + params.put("value", (Integer) value); + } else if (value instanceof Boolean) { + params.put("value", (Boolean) value); + } else { + params.set("value", JacksonUtil.valueToTree(value)); + } + + ObjectNode setRpcRequest = JacksonUtil.newObjectNode(); + setRpcRequest.put("method", "Execute"); + setRpcRequest.set("params", params); + + return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), + JacksonUtil.toString(setRpcRequest), String.class, status().isOk()); } } 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/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 271866d23c..3b7248ee72 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -25,10 +25,12 @@ import org.eclipse.californium.core.server.resources.Resource; import org.eclipse.californium.elements.config.Configuration; import org.eclipse.californium.scandium.DTLSConnector; import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardExecutors; +import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.UnknownHostException; @@ -42,22 +44,41 @@ import static org.eclipse.californium.core.config.CoapConfig.DEFAULT_BLOCKWISE_S @Slf4j @Component @TbCoapServerComponent -public class DefaultCoapServerService implements CoapServerService { +public class DefaultCoapServerService implements CoapServerService, SmartInitializingSingleton { @Autowired private CoapServerContext coapServerContext; private CoapServer server; - private TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier; + private volatile TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier; private ScheduledExecutorService dtlsSessionsExecutor; + private volatile DTLSConnector dtlsConnector; + + private volatile CoapEndpoint dtlsCoapEndpoint; + @PostConstruct public void init() throws UnknownHostException { createCoapServer(); } + @Override + public void afterSingletonsInstantiated() { + if (isDtlsEnabled()) { + coapServerContext.getDtlsSettings().registerReloadCallback(() -> { + try { + log.info("CoAP DTLS certificates reloaded. Recreating DTLS endpoint..."); + recreateDtlsEndpoint(); + log.info("CoAP DTLS endpoint recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate CoAP DTLS endpoint after certificate reload", e); + } + }); + } + } + @PreDestroy public void shutdown() { if (dtlsSessionsExecutor != null) { @@ -83,16 +104,7 @@ public class DefaultCoapServerService implements CoapServerService { } private CoapServer createCoapServer() throws UnknownHostException { - Configuration networkConfig = new Configuration(); - networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true); - networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true); - networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS); - networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024); - networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); - networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024); - networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024); - networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4); - networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort()); + Configuration networkConfig = createNetworkConfiguration(); server = new CoapServer(networkConfig); CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); @@ -104,16 +116,7 @@ public class DefaultCoapServerService implements CoapServerService { CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); server.addEndpoint(noSecCoapEndpoint); if (isDtlsEnabled()) { - CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); - TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); - DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); - networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); - dtlsCoapEndpointBuilder.setConfiguration(networkConfig); - DTLSConnector connector = new DTLSConnector(dtlsConnectorConfig); - dtlsCoapEndpointBuilder.setConnector(connector); - CoapEndpoint dtlsCoapEndpoint = dtlsCoapEndpointBuilder.build(); - server.addEndpoint(dtlsCoapEndpoint); - tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + createDtlsEndpoint(networkConfig); dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); } @@ -137,4 +140,106 @@ public class DefaultCoapServerService implements CoapServerService { return tbDtlsCertificateVerifier.getDtlsSessionReportTimeout(); } + private Configuration createNetworkConfiguration() { + Configuration networkConfig = new Configuration(); + networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true); + networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true); + networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS); + networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024); + networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); + networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024); + networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024); + networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4); + networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort()); + return networkConfig; + } + + // Note: this method has a side effect — it sets COAP_SECURE_PORT on the provided networkConfig. + private DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { + TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); + DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); + networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); + return dtlsConnectorConfig; + } + + private CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) { + CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); + dtlsCoapEndpointBuilder.setConfiguration(networkConfig); + dtlsCoapEndpointBuilder.setConnector(connector); + return dtlsCoapEndpointBuilder.build(); + } + + private void createDtlsEndpoint(Configuration networkConfig) throws UnknownHostException { + DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig); + DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); + CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + server.addEndpoint(newEndpoint); + + dtlsConnector = newConnector; + dtlsCoapEndpoint = newEndpoint; + tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + } + + private DTLSConnector createDtlsConnector(DtlsConnectorConfig config) { + return new DTLSConnector(config); + } + + private synchronized void recreateDtlsEndpoint() throws IOException { + CoapEndpoint oldDtlsEndpoint = dtlsCoapEndpoint; + DTLSConnector oldDtlsConnector = dtlsConnector; + + Configuration networkConfig = createNetworkConfiguration(); + + log.info("Creating new DTLS endpoint with updated certificates..."); + + DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig); + DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); + CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + + // We must stop the old endpoint before starting the new one so they don't compete for the same DTLS port. + // This creates a brief window where the port is unbound; + // if the new endpoint fails to start, we attempt to restore the old one (see rollback below). + if (oldDtlsEndpoint != null) { + log.info("Stopping old DTLS endpoint to release the port..."); + server.getEndpoints().remove(oldDtlsEndpoint); + oldDtlsEndpoint.stop(); + } + + server.addEndpoint(newEndpoint); + try { + newEndpoint.start(); + } catch (IOException e) { + log.error("Failed to start new DTLS endpoint, restoring old endpoint", e); + server.getEndpoints().remove(newEndpoint); + newEndpoint.destroy(); + newConnector.destroy(); + // Attempt to restore the old endpoint + if (oldDtlsEndpoint != null) { + try { + server.addEndpoint(oldDtlsEndpoint); + oldDtlsEndpoint.start(); + log.info("Old DTLS endpoint restored successfully."); + } catch (IOException restoreEx) { + log.error("Failed to restore old DTLS endpoint", restoreEx); + } + } + throw e; + } + log.info("New DTLS endpoint started successfully."); + + // Only swap instance fields after a successful start + dtlsConnector = newConnector; + dtlsCoapEndpoint = newEndpoint; + tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + + // Destroy old resources after a successful swap + if (oldDtlsEndpoint != null) { + if (oldDtlsConnector != null) { + oldDtlsConnector.destroy(); + } + oldDtlsEndpoint.destroy(); + log.info("Old DTLS endpoint destroyed."); + } + } + } diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java index af9dc2829f..7f63953fd3 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java @@ -100,6 +100,10 @@ public class TbCoapDtlsSettings { @Autowired(required = false) private TbServiceInfoProvider serviceInfoProvider; + public void registerReloadCallback(Runnable callback) { + coapDtlsCredentialsConfig.registerReloadCallback(callback); + } + public DtlsConnectorConfig dtlsConnectorConfig(Configuration configuration) throws UnknownHostException { DtlsConnectorConfig.Builder configBuilder = new DtlsConnectorConfig.Builder(configuration); configBuilder.setAddress(getInetSocketAddress()); @@ -154,5 +158,5 @@ public class TbCoapDtlsSettings { } return null; } -} +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java new file mode 100644 index 0000000000..11e278b4f5 --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java @@ -0,0 +1,349 @@ +/** + * 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.coapserver; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.eclipse.californium.core.CoapClient; +import org.eclipse.californium.core.CoapResource; +import org.eclipse.californium.core.CoapResponse; +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.config.CoapConfig; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.server.resources.CoapExchange; +import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.elements.util.SslContextUtil; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConfig; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.eclipse.californium.scandium.dtls.CertificateType; +import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider; +import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsType; + +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CLIENT_AUTHENTICATION_MODE; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_ROLE; +import static org.eclipse.californium.scandium.config.DtlsConfig.DtlsRole.SERVER_ONLY; + +public class CoapDtlsCertificateReloadIntegrationTest { + + private static final String TEST_RESOURCE_PATH = "test"; + private static final String TEST_PAYLOAD = "hello-dtls"; + + @TempDir + Path tempDir; + + private CoapServer coapServer; + + @AfterEach + public void teardown() { + if (coapServer != null) { + coapServer.destroy(); + } + } + + @Test + public void givenDtlsServer_whenCertFileChangedAndReloadTriggered_thenNewEndpointServesNewCert() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointA); + coapServer.start(); + + CoapResponse responseA = doDtlsRequest(dtlsPort, certA); + assertThat(responseA).isNotNull(); + assertThat(responseA.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + assertThat(responseA.getResponseText()).isEqualTo(TEST_PAYLOAD); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpointA); + endpointA.stop(); + + CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointB); + endpointB.start(); + endpointA.destroy(); + + CoapResponse responseB = doDtlsRequest(dtlsPort, certB); + assertThat(responseB).isNotNull(); + assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + assertThat(responseB.getResponseText()).isEqualTo(TEST_PAYLOAD); + } + + @Test + public void givenDtlsServer_whenCertReloaded_thenOldCertClientFails() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointA); + coapServer.start(); + + CoapResponse responseA = doDtlsRequest(dtlsPort, certA); + assertThat(responseA).isNotNull(); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpointA); + endpointA.stop(); + CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointB); + endpointB.start(); + endpointA.destroy(); + + CoapResponse failedResponse = doDtlsRequest(dtlsPort, certA); + assertThat(failedResponse).isNull(); + + CoapResponse responseB = doDtlsRequest(dtlsPort, certB); + assertThat(responseB).isNotNull(); + assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + } + + @Test + public void givenDtlsServer_whenReloadWithSameCert_thenConnectionStillWorks() throws Exception { + KeyPair keyPair = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(keyPair, "CN=Server"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, cert); + writeKeyPem(keyFile, keyPair); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpoint1 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpoint1); + coapServer.start(); + + CoapResponse response1 = doDtlsRequest(dtlsPort, cert); + assertThat(response1).isNotNull(); + assertThat(response1.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpoint1); + endpoint1.stop(); + CoapEndpoint endpoint2 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpoint2); + endpoint2.start(); + endpoint1.destroy(); + + CoapResponse response2 = doDtlsRequest(dtlsPort, cert); + assertThat(response2).isNotNull(); + assertThat(response2.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + } + + private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) { + PemSslCredentials pem = new PemSslCredentials(); + pem.setCertFile(certFile.toAbsolutePath().toString()); + pem.setKeyFile(keyFile.toAbsolutePath().toString()); + + SslCredentialsConfig config = new SslCredentialsConfig("CoAP DTLS Test", false); + config.setEnabled(true); + config.setType(SslCredentialsType.PEM); + config.setPem(pem); + config.setKeystore(new KeystoreSslCredentials()); + config.init(); + return config; + } + + private CoapEndpoint buildDtlsEndpointFromCredentials(Configuration config, SslCredentials credentials, int port) { + DtlsConnectorConfig.Builder dtlsBuilder = new DtlsConnectorConfig.Builder(config); + dtlsBuilder.setAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), port)); + dtlsBuilder.set(DTLS_ROLE, SERVER_ONLY); + dtlsBuilder.set(DTLS_RETRANSMISSION_TIMEOUT, 3000, MILLISECONDS); + dtlsBuilder.set(DTLS_CLIENT_AUTHENTICATION_MODE, + org.eclipse.californium.elements.config.CertificateAuthenticationMode.WANTED); + + SslContextUtil.Credentials serverCreds = new SslContextUtil.Credentials( + credentials.getPrivateKey(), null, credentials.getCertificateChain()); + + dtlsBuilder.setCertificateIdentityProvider( + new SingleCertificateProvider(serverCreds.getPrivateKey(), serverCreds.getCertificateChain(), + Collections.singletonList(CertificateType.X_509))); + + dtlsBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustAllCertificates() + .build()); + + DTLSConnector connector = new DTLSConnector(dtlsBuilder.build()); + + CoapEndpoint.Builder endpointBuilder = new CoapEndpoint.Builder(); + endpointBuilder.setConfiguration(config); + endpointBuilder.setConnector(connector); + return endpointBuilder.build(); + } + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(256); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { + X500Name subject = new X500Name(subjectDn); + Date now = new Date(); + Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(System.nanoTime()), now, expiry, + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withECDSA").build(kp.getPrivate()))); + } + + private void writeCertPem(Path path, X509Certificate cert) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + } + + private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + } + } + + private Configuration createServerConfig() { + Configuration config = new Configuration(); + config.set(CoapConfig.MAX_RETRANSMIT, 2); + config.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); + return config; + } + + private CoapResponse doDtlsRequest(int port, X509Certificate trustedCert) { + try { + Configuration clientConfig = new Configuration(); + clientConfig.set(CoapConfig.MAX_RETRANSMIT, 1); + clientConfig.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY); + clientConfig.set(DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT, 2000, MILLISECONDS); + clientConfig.set(DtlsConfig.DTLS_USE_HELLO_VERIFY_REQUEST, false); + clientConfig.set(DtlsConfig.DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT, false); + + DtlsConnectorConfig.Builder clientDtls = new DtlsConnectorConfig.Builder(clientConfig); + clientDtls.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustedCertificates(trustedCert) + .build()); + + DTLSConnector clientConnector = new DTLSConnector(clientDtls.build()); + CoapEndpoint clientEndpoint = new CoapEndpoint.Builder() + .setConfiguration(clientConfig) + .setConnector(clientConnector) + .build(); + + CoapClient client = new CoapClient("coaps://127.0.0.1:" + port + "/" + TEST_RESOURCE_PATH); + client.setEndpoint(clientEndpoint); + client.setTimeout((long) 5000); + + try { + clientEndpoint.start(); + return client.get(); + } finally { + client.shutdown(); + clientEndpoint.destroy(); + } + } catch (Exception e) { + return null; + } + } + + private int findAvailablePort() throws Exception { + try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) { + return socket.getLocalPort(); + } + } + + private static class TestResource extends CoapResource { + TestResource() { + super(TEST_RESOURCE_PATH); + } + + @Override + public void handleGET(CoapExchange exchange) { + exchange.respond(CoAP.ResponseCode.CONTENT, TEST_PAYLOAD); + } + + } + +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java new file mode 100644 index 0000000000..f22aaf5c7e --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java @@ -0,0 +1,246 @@ +/** + * 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.coapserver; + +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.network.Endpoint; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class CoapDtlsCertificateReloadTest { + + @Mock + private CoapServerContext mockCoapServerContext; + + @Mock + private TbCoapDtlsSettings mockDtlsSettings; + + @Mock + private CoapServer mockCoapServer; + + @Mock + private CoapEndpoint mockDtlsEndpoint; + + @Mock + private DTLSConnector mockDtlsConnector; + + private DefaultCoapServerService coapServerService; + + @BeforeEach + public void setup() { + coapServerService = new DefaultCoapServerService(); + ReflectionTestUtils.setField(coapServerService, "coapServerContext", mockCoapServerContext); + + when(mockCoapServerContext.getHost()).thenReturn("localhost"); + when(mockCoapServerContext.getPort()).thenReturn(5683); + doAnswer(invocation -> { + invocation.getArgument(0); + return null; + }).when(mockDtlsSettings).registerReloadCallback(any()); + } + + @Test + public void givenDtlsEnabled_whenRegisterCertificateReloadCallback_thenShouldRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenDtlsNotEnabled_whenRegisterCertificateReloadCallback_thenShouldNotRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); + + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + verify(mockDtlsSettings, never()).registerReloadCallback(any()); + } + + @Test + public void givenReloadCallbackInvoked_whenNewEndpointCreationFails_thenOldEndpointIsPreserved() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + // dtlsSettings.dtlsConnectorConfig() isn't mocked, so the callback will throw. + // The old endpoint should not be stopped/destroyed when creation of the new one fails. + reloadCallback.run(); + + verify(mockDtlsEndpoint, never()).stop(); + verify(mockDtlsConnector, never()).destroy(); + } + + @Test + public void givenDtlsEnabled_whenInit_thenShouldRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + when(mockCoapServerContext.getHost()).thenReturn("localhost"); + when(mockCoapServerContext.getPort()).thenReturn(5683); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + verify(mockDtlsSettings).registerReloadCallback(any(Runnable.class)); + } + + @Test + public void givenReloadCallback_whenInvokedMultipleTimes_thenShouldRegisterOnce() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + assertThat(reloadCallback).isNotNull(); + } + + @Test + public void givenReloadCallback_whenSuccessful_thenOldEndpointRemovedFromServer() throws Exception { + // GIVEN + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class); + when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + List endpointsList = new CopyOnWriteArrayList<>(); + endpointsList.add(mockDtlsEndpoint); + when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); + + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + + try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, + (builder, context) -> { + when(builder.build()).thenReturn(mockNewEndpoint); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + })) { + + // WHEN + ReflectionTestUtils.invokeMethod(coapServerService, "recreateDtlsEndpoint"); + + // THEN + assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).destroy(); + verify(mockDtlsConnector).destroy(); + verify(mockCoapServer).addEndpoint(mockNewEndpoint); + verify(mockNewEndpoint).start(); + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); + } + } + + @Test + public void givenReloadCallback_whenStartFails_thenNewResourcesCleanedAndOldRestored() throws Exception { + // GIVEN + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + List endpointsList = new CopyOnWriteArrayList<>(); + endpointsList.add(mockDtlsEndpoint); + when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); + + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); + + try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, + (builder, context) -> { + when(builder.build()).thenReturn(mockNewEndpoint); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + })) { + + // WHEN + coapServerService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + reloadCallback.run(); + + // THEN - new resources cleaned up + DTLSConnector constructedConnector = dtlsMock.constructed().get(0); + verify(mockNewEndpoint).destroy(); + verify(constructedConnector).destroy(); + assertThat(endpointsList).doesNotContain(mockNewEndpoint); + // Old endpoint was stopped to release port, then restored after new one failed + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).start(); + // Old fields preserved + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsConnector")).isSameAs(mockDtlsConnector); + } + } + +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java index c75348d97e..9538670e25 100644 --- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java @@ -18,8 +18,8 @@ package org.thingsboard.server.coapserver; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; @@ -41,11 +41,11 @@ class TbCoapDtlsSettingsTest { @Autowired TbCoapDtlsSettings coapDtlsSettings; - @MockBean + @MockitoBean SslCredentialsConfig sslCredentialsConfig; - @MockBean + @MockitoBean private TransportService transportService; - @MockBean + @MockitoBean private TbServiceInfoProvider serviceInfoProvider; @Test diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java index f412c5488a..406263aaed 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import java.util.Collection; +import java.util.Set; public interface ImageService { @@ -38,6 +39,8 @@ public interface ImageService { TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key); + Set getAllImageKeysByTenantId(TenantId tenantId); + TbResourceInfo getPublicImageInfoByKey(String publicResourceKey); PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink); 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/ResourceUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java index 725aa7921c..13c75feed0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java @@ -51,11 +51,9 @@ public class ResourceUtils { return true; } else { try { - URL url = Resources.getResource(path); - if (url != null) { - return true; - } - } catch (IllegalArgumentException e) {} + Resources.getResource(path); + return true; + } catch (IllegalArgumentException ignored) {} } return false; } @@ -93,9 +91,9 @@ public class ResourceUtils { } } catch (Exception e) { if (e instanceof NullPointerException) { - log.warn("Unable to find resource: " + filePath); + log.warn("Unable to find resource: {}", filePath); } else { - log.warn("Unable to find resource: " + filePath, e); + log.warn("Unable to find resource: {}", filePath, e); } } throw new RuntimeException("Unable to find resource: " + filePath); @@ -113,15 +111,19 @@ public class ResourceUtils { return resourceFile.getAbsolutePath(); } else { URL url = classLoader.getResource(filePath); + if (url == null) { + throw new RuntimeException("Unable to find resource: " + filePath); + } return url.toURI().toString(); } } catch (Exception e) { if (e instanceof NullPointerException) { - log.warn("Unable to find resource: " + filePath); + log.warn("Unable to find resource: {}", filePath); } else { - log.warn("Unable to find resource: " + filePath, e); + log.warn("Unable to find resource: {}", filePath, e); } throw new RuntimeException("Unable to find resource: " + filePath); } } + } 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/notification/rule/NotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java index 0475a9a02d..9692175876 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java @@ -48,7 +48,7 @@ import java.util.UUID; @DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class) }) @JsonIgnoreProperties(ignoreUnknown = true) -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class) @JsonSubTypes({ @Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class), @Type(name = "ENTITY_ACTION", value = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java index 23c0241d51..d5f61fdbeb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java @@ -93,7 +93,10 @@ public class OAuth2Client extends BaseDataWithAdditionalInfo imp @Schema(description = "List of platforms for which usage of the OAuth2 client is allowed (empty for all allowed)") @Length(fieldName = "platforms") private List platforms; - @Schema(description = "Additional info of OAuth2 client (e.g. providerName)", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "Additional info of OAuth2 client. " + + "Must include: 'providerName' (string, name of the OAuth2 provider).", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "{\"providerName\":\"Google\"}") private JsonNode additionalInfo; public OAuth2Client() { @@ -128,4 +131,5 @@ public class OAuth2Client extends BaseDataWithAdditionalInfo imp public String getName() { return title; } + } 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/sync/ie/EntityExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java index 3e04074236..18358c8d68 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java @@ -50,7 +50,7 @@ import java.util.List; import java.util.Map; @JsonIgnoreProperties(ignoreUnknown = true) -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true, defaultImpl = EntityExportData.class) @JsonSubTypes({ @Type(name = "DEVICE", value = DeviceExportData.class), @Type(name = "RULE_CHAIN", value = RuleChainExportData.class), 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/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java new file mode 100644 index 0000000000..8fb6214dac --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java @@ -0,0 +1,40 @@ +/** + * 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; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ResourceUtilsTest { + + @Test + public void givenNonExistentResource_whenGetUri_thenThrowsRuntimeException() { + assertThatThrownBy(() -> ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Unable to find resource"); + } + + @Test + public void givenExistingClasspathResource_whenGetUri_thenReturnsNonNullUri() { + String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "org/thingsboard/server/common/data/ResourceUtilsTest.class"); + + assertThat(result).isNotNull(); + assertThat(result).contains("ResourceUtilsTest"); + } + +} diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java index 8ae3499d63..2d9e77633d 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java @@ -33,6 +33,11 @@ public class StringDataPoint extends AbstractDataPoint { this.value = deduplicate ? TbStringPool.intern(value) : value; } + @Override + public boolean getBool() { + return Boolean.parseBoolean(value); + } + @Override public double getDouble() { return Double.parseDouble(value); diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java index f6da57e24c..6da25c260a 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java @@ -210,6 +210,8 @@ public class RepositoryUtils { return true; } } + // Vacuously true when OR is called with an empty filter list. Unreachable via checkFilters + // (which guards on isHasKeyFilters()), but kept defensive for any future direct caller. return keyFilters.isEmpty(); } else { for (EdqsFilter keyFilter : keyFilters) { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index 0f50284d6c..2b3f273c70 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -17,7 +17,7 @@ package org.thingsboard.server.common.msg; import lombok.extern.slf4j.Slf4j; import org.bouncycastle.crypto.digests.SHA3Digest; -import org.bouncycastle.pqc.legacy.math.linearalgebra.ByteUtils; +import org.bouncycastle.util.encoders.Hex; /** * @author Valerii Sosliuk @@ -66,7 +66,7 @@ public class EncryptionUtil { md.update(dataBytes, 0, dataBytes.length); byte[] hashedBytes = new byte[256 / 8]; md.doFinal(hashedBytes, 0); - String sha3Hash = ByteUtils.toHexString(hashedBytes); + String sha3Hash = Hex.toHexString(hashedBytes); return sha3Hash; } 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/queue/pom.xml b/common/queue/pom.xml index 1cb159b3af..a434552363 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -68,10 +68,6 @@ org.apache.kafka kafka-clients - - at.yawk.lz4 - lz4-java - com.google.cloud google-cloud-pubsub diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java index 87f566c90f..dcf9459965 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java @@ -98,7 +98,10 @@ public abstract class AbstractTbQueueConsumerTemplate i doSubscribe(partitions); subscribed = true; } - records = partitions.isEmpty() ? emptyList() : doPoll(durationInMillis); + if (partitions.isEmpty()) { + return sleepAndReturnEmpty(startNanos, durationInMillis); + } + records = doPoll(durationInMillis); } finally { consumerLock.unlock(); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java index bf884958eb..279a2ddc7f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java @@ -32,6 +32,7 @@ import org.thingsboard.server.queue.util.PropertyUtils; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType.SOFT; @@ -59,41 +60,48 @@ public class DefaultNotificationDeduplicationService implements NotificationDedu } private boolean alreadyProcessed(NotificationRuleTrigger trigger, String deduplicationKey, boolean onlyLocalCache) { - Long lastProcessedTs = localCache.get(deduplicationKey); - if (lastProcessedTs == null && !onlyLocalCache) { - Cache externalCache = getExternalCache(); - if (externalCache != null) { - lastProcessedTs = externalCache.get(deduplicationKey, Long.class); - } else { - log.warn("Sent notifications cache is not set up"); + long deduplicationDuration = getDeduplicationDuration(trigger); + final long now = System.currentTimeMillis(); + boolean[] result = {false}; + + localCache.compute(deduplicationKey, (key, lastProcessedTs) -> { + if (lastProcessedTs == null && !onlyLocalCache) { + Cache externalCache = getExternalCache(); + if (externalCache != null) { + lastProcessedTs = externalCache.get(key, Long.class); + if (lastProcessedTs != null && lastProcessedTs > now + TimeUnit.HOURS.toMillis(1)) { + log.warn("Discarding dedup entry from external cache for key '{}': timestamp is {} ms in the future", + key, lastProcessedTs - now); + lastProcessedTs = null; + } + } else { + log.warn("Sent notifications cache is not set up"); + } } - } - boolean alreadyProcessed = false; - long deduplicationDuration = getDeduplicationDuration(trigger); - if (lastProcessedTs != null) { - long passed = System.currentTimeMillis() - lastProcessedTs; - log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms", - trigger.getType(), deduplicationKey, deduplicationDuration, passed); - if (deduplicationDuration == 0 || passed <= deduplicationDuration) { - alreadyProcessed = true; + if (lastProcessedTs != null) { + long passed = now - lastProcessedTs; + log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms", + trigger.getType(), key, deduplicationDuration, passed); + if (deduplicationDuration == 0 || passed <= deduplicationDuration) { + result[0] = true; + return lastProcessedTs; + } } - } - if (!alreadyProcessed) { - lastProcessedTs = System.currentTimeMillis(); - } - localCache.put(deduplicationKey, lastProcessedTs); + return now; + }); + if (!onlyLocalCache) { - if (!alreadyProcessed || deduplicationDuration == 0) { + if (!result[0] || deduplicationDuration == 0) { // if lastProcessedTs is changed or if deduplicating infinitely (so that cache value not removed by ttl) Cache externalCache = getExternalCache(); if (externalCache != null) { - externalCache.put(deduplicationKey, lastProcessedTs); + externalCache.put(deduplicationKey, now); } } } - return alreadyProcessed; + return result[0]; } public static String getDeduplicationKey(NotificationRuleTrigger trigger, NotificationRule rule) { diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java new file mode 100644 index 0000000000..a016408ad4 --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java @@ -0,0 +1,146 @@ +/** + * 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.queue.common; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.TbQueueMsg; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.spy; +import static org.mockito.BDDMockito.times; +import static org.mockito.BDDMockito.verify; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class AbstractTbQueueConsumerTemplateTest { + + private static final long POLL_DURATION_MS = 100L; + private static final long SLEEP_TOLERANCE_MS = 20L; + + @Test + public void givenEmptyPartitionsAndLongPollingSupported_whenPoll_thenSleepsAndDoesNotCallDoPoll() { + // Regression: with empty partitions AND isLongPollingSupported()==true (e.g. Kafka), + // poll() previously returned instantly with no sleep, causing the consumer loop to busy-spin. + TestConsumer consumer = spy(new TestConsumer("test-topic", true)); + consumer.subscribe(Collections.emptySet()); + + long startNs = System.nanoTime(); + List result = consumer.poll(POLL_DURATION_MS); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + + assertThat(result, is(empty())); + verify(consumer, never()).doPoll(anyLong()); + assertThat("poll() must sleep ~durationInMillis when partitions are empty (no busy-wait)", + elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS)); + } + + @Test + public void givenEmptyPartitionsAndNoLongPolling_whenPoll_thenSleepsAndDoesNotCallDoPoll() { + TestConsumer consumer = spy(new TestConsumer("test-topic", false)); + consumer.subscribe(Collections.emptySet()); + + long startNs = System.nanoTime(); + List result = consumer.poll(POLL_DURATION_MS); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + + assertThat(result, is(empty())); + verify(consumer, never()).doPoll(anyLong()); + assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS)); + } + + @Test + public void givenNonEmptyPartitions_whenPoll_thenCallsDoPoll() { + TestConsumer consumer = spy(new TestConsumer("test-topic", true)); + consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true))); + + List result = consumer.poll(POLL_DURATION_MS); + + assertThat(result, is(empty())); + verify(consumer, times(1)).doPoll(POLL_DURATION_MS); + } + + @Test + public void givenPartitionsBecomeEmptyAfterRebalance_whenPollAgain_thenStopsCallingDoPoll() { + // Reproduces the observed trigger: a rebalance leaves the consumer with an empty + // partition assignment. Subsequent poll() calls must not busy-spin or call doPoll(). + TestConsumer consumer = spy(new TestConsumer("test-topic", true)); + consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true))); + consumer.poll(POLL_DURATION_MS); + verify(consumer, times(1)).doPoll(POLL_DURATION_MS); + + consumer.subscribe(Collections.emptySet()); + + long startNs = System.nanoTime(); + List result = consumer.poll(POLL_DURATION_MS); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + + assertThat(result, is(empty())); + verify(consumer, times(1)).doPoll(anyLong()); + assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS)); + } + + static class TestConsumer extends AbstractTbQueueConsumerTemplate { + + private final boolean longPollingSupported; + + TestConsumer(String topic, boolean longPollingSupported) { + super(topic); + this.longPollingSupported = longPollingSupported; + } + + @Override + protected List doPoll(long durationInMillis) { + return Collections.emptyList(); + } + + @Override + protected TbQueueMsg decode(Object record) { + return null; + } + + @Override + protected void doSubscribe(Set partitions) { + } + + @Override + protected void doCommit() { + } + + @Override + protected void doUnsubscribe() { + } + + @Override + protected boolean isLongPollingSupported() { + return longPollingSupported; + } + } + +} diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java new file mode 100644 index 0000000000..826c1e1ef6 --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.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.queue.notification; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.notification.rule.NotificationRule; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultNotificationDeduplicationServiceTest { + + private static final int TIMEOUT = 30; + + private DefaultNotificationDeduplicationService deduplicationService; + private CacheManager cacheManager; + + @BeforeEach + void setUp() { + deduplicationService = new DefaultNotificationDeduplicationService(); + deduplicationService.setDeduplicationDurations(""); + cacheManager = new ConcurrentMapCacheManager(CacheConstants.SENT_NOTIFICATIONS_CACHE); + ReflectionTestUtils.setField(deduplicationService, "cacheManager", cacheManager); + } + + @Test + void testFirstTriggerIsNotDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testSecondTriggerIsDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); + } + + @Test + void testTriggerPassesAfterDeduplicationWindowExpires() { + NotificationRuleTrigger trigger = mockTrigger(50); // 50ms dedup window + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + + try { + Thread.sleep(200); // wait well past the 50ms window + } catch (InterruptedException ignored) {} + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testFutureTimestampFromExternalCacheIsDiscarded() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); + + // Put a timestamp 2 hours in the future into external cache + Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); + externalCache.put(dedupKey, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2)); + + // Should NOT be deduplicated — future timestamp must be discarded + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testValidTimestampFromExternalCacheIsDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); + + // Put a recent timestamp into external cache + Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); + externalCache.put(dedupKey, System.currentTimeMillis()); + + // Should be deduplicated — valid external cache entry + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); + } + + @Test + void testConcurrentTriggersProduceExactlyOneNonDeduplicated() throws Exception { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + int threadCount = 10; + CyclicBarrier barrier = new CyclicBarrier(threadCount); + List results = new CopyOnWriteArrayList<>(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + barrier.await(TIMEOUT, TimeUnit.SECONDS); + } catch (Exception ignored) {} + results.add(deduplicationService.alreadyProcessed(trigger, rule)); + }); + } + executor.shutdown(); + assertThat(executor.awaitTermination(TIMEOUT, TimeUnit.SECONDS)).isTrue(); + + assertThat(results).hasSize(threadCount); + assertThat(results.stream().filter(r -> !r).count()) + .as("exactly one trigger should pass through deduplication") + .isEqualTo(1); + } finally { + executor.shutdownNow(); + } + } + + private NotificationRuleTrigger mockTrigger(long deduplicationDurationMs) { + NotificationRuleTrigger trigger = mock(NotificationRuleTrigger.class); + when(trigger.getType()).thenReturn(NotificationRuleTriggerType.RESOURCES_SHORTAGE); + when(trigger.getDeduplicationKey()).thenReturn("test:dedup:key"); + when(trigger.getDefaultDeduplicationDuration()).thenReturn(deduplicationDurationMs); + when(trigger.getDeduplicationStrategy()).thenReturn(NotificationRuleTrigger.DeduplicationStrategy.ONLY_MATCHING); + return trigger; + } + + private NotificationRule mockRule() { + NotificationRule rule = mock(NotificationRule.class); + when(rule.getDeduplicationKey()).thenReturn("rule:key"); + return rule; + } + +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java index b03a460c31..2452bd8d61 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java @@ -73,7 +73,7 @@ public class TbelCfTsRollingArg implements TbelCfArg, Iterable 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/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index 56e90e4b7c..d0205314bc 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -76,10 +76,6 @@ import java.util.List; import java.util.UUID; import java.util.function.Consumer; - -/** - * @author Andrew Shvayka - */ @RestController @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @RequestMapping("/api/v1") diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java index 830e081dfd..db72ba747c 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java @@ -26,9 +26,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportContext; -/** - * Created by ashvayka on 04.10.18. - */ @Slf4j @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @Component @@ -52,4 +49,5 @@ public class HttpTransportContext extends TransportContext { } }; } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 412036677a..9b370d0b71 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -29,6 +29,7 @@ import org.eclipse.leshan.server.californium.bootstrap.LwM2mBootstrapPskStore; import org.eclipse.leshan.server.californium.bootstrap.endpoint.CaliforniumBootstrapServerEndpointsProvider; import org.eclipse.leshan.server.californium.bootstrap.endpoint.coap.CoapBootstrapServerProtocolProvider; import org.eclipse.leshan.server.californium.bootstrap.endpoint.coaps.CoapsBootstrapServerProtocolProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; @@ -55,7 +56,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se @Component @TbLwM2mBootstrapTransportComponent @RequiredArgsConstructor -public class LwM2MTransportBootstrapService { +public class LwM2MTransportBootstrapService implements SmartInitializingSingleton { private final LwM2MTransportServerConfig serverConfig; private final LwM2MTransportBootstrapConfig bootstrapConfig; @@ -63,7 +64,20 @@ public class LwM2MTransportBootstrapService { private final LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore; private final TransportService transportService; private final TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; - private LeshanBootstrapServer server; + private volatile LeshanBootstrapServer server; + + @Override + public void afterSingletonsInstantiated() { + bootstrapConfig.registerServerReloadCallback(() -> { + try { + log.info("LwM2M Bootstrap certificates reloaded. Recreating bootstrap server..."); + recreateBootstrapServer(); + log.info("LwM2M Bootstrap server recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate LwM2M Bootstrap server after certificate reload", e); + } + }); + } @PostConstruct public void init() { @@ -110,7 +124,7 @@ public class LwM2MTransportBootstrapService { // Create Californium Configuration Configuration serverCoapConfig = endpointsBuilder.createDefaultConfiguration(); - getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(),serverConfig); + getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(), serverConfig); serverCoapConfig.setTransient(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY); serverCoapConfig.set(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY, serverConfig.isRecommendedCiphers()); serverCoapConfig.setTransient(DtlsConfig.DTLS_CONNECTION_ID_LENGTH); @@ -119,7 +133,7 @@ public class LwM2MTransportBootstrapService { serverCoapConfig.set(DTLS_RETRANSMISSION_TIMEOUT, serverConfig.getDtlsRetransmissionTimeout(), MILLISECONDS); if (serverConfig.getDtlsCidLength() != null) { - setDtlsConnectorConfigCidLength( serverCoapConfig, serverConfig.getDtlsCidLength()); + setDtlsConnectorConfigCidLength(serverCoapConfig, serverConfig.getDtlsCidLength()); } /* Create DTLS Config */ @@ -164,4 +178,42 @@ public class LwM2MTransportBootstrapService { builder.setTrustedCertificates(new X509Certificate[0]); } } + + private synchronized void recreateBootstrapServer() { + LeshanBootstrapServer oldServer = this.server; + + log.info("Creating new LwM2M Bootstrap server with updated certificates..."); + LeshanBootstrapServer newServer = getLhBootstrapServer(); + + // Stop (not destroy) the old server to release ports but keep it restartable for rollback + if (oldServer != null) { + log.info("Stopping old LwM2M Bootstrap server to release ports..."); + oldServer.stop(); + } + + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M Bootstrap server", e); + newServer.destroy(); + // Attempt to restart the old server (only stopped, not destroyed) + if (oldServer != null) { + try { + oldServer.start(); + log.info("Restored old LwM2M Bootstrap server successfully."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M Bootstrap server", restoreEx); + } + } + throw e; + } + this.server = newServer; + log.info("New LwM2M Bootstrap server started successfully."); + + // Destroy the old server only after a successful swap + if (oldServer != null) { + oldServer.destroy(); + } + } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java index b15a757ae3..d3bf9e33fe 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.config; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -27,6 +28,9 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + @Slf4j @Component @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || '${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core'") @@ -62,8 +66,33 @@ public class LwM2MTransportBootstrapConfig implements LwM2MSecureServerConfig { @Qualifier("lwm2mBootstrapCredentials") private SslCredentialsConfig credentialsConfig; + private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); + + @PostConstruct + public void init() { + credentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Bootstrap DTLS certificates reloaded. Triggering bootstrap server reload..."); + notifyServerReload(); + }); + } + + public void registerServerReloadCallback(Runnable callback) { + serverReloadCallbacks.add(callback); + } + + private void notifyServerReload() { + for (Runnable callback : serverReloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("Error executing LwM2M bootstrap server reload callback", e); + } + } + } + @Override public SslCredentials getSslCredentials() { return this.credentialsConfig.getCredentials(); } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java index 5373c56320..2cb6c425a7 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.transport.lwm2m.config; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -26,11 +28,17 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.TbProperty; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; @Slf4j @Component @@ -38,6 +46,13 @@ import java.util.List; @ConfigurationProperties(prefix = "transport.lwm2m") public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { + private static final long RELOAD_DEBOUNCE_SECONDS = 2; + + private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); + private final ScheduledExecutorService reloadDebouncer = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("lwm2m-reload-debouncer")); + + private volatile ScheduledFuture pendingReload; + @Getter @Value("${transport.lwm2m.dtls.retransmission_timeout:9000}") private int dtlsRetransmissionTimeout; @@ -134,6 +149,52 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { @Qualifier("lwm2mTrustCredentials") private SslCredentialsConfig trustCredentialsConfig; + @PostConstruct + public void init() { + credentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Server DTLS certificates reloaded. Scheduling debounced server reload..."); + scheduleServerReload(); + }); + + trustCredentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Trust certificates reloaded. Scheduling debounced server reload..."); + scheduleServerReload(); + }); + } + + @PreDestroy + public void destroy() { + reloadDebouncer.shutdownNow(); + } + + public void registerServerReloadCallback(Runnable callback) { + serverReloadCallbacks.add(callback); + } + + /** + * Debounces server reload so that if both server and trust credentials change in the same + * poll cycle, only the 'single server recreation' is triggered after both are reloaded. + */ + private synchronized void scheduleServerReload() { + if (pendingReload != null) { + pendingReload.cancel(false); + } + pendingReload = reloadDebouncer.schedule(() -> { + log.info("Debounce window elapsed. Triggering LwM2M server reload..."); + notifyServerReload(); + }, RELOAD_DEBOUNCE_SECONDS, TimeUnit.SECONDS); + } + + private void notifyServerReload() { + for (Runnable callback : serverReloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("Error executing LwM2M server reload callback", e); + } + } + } + @Override public SslCredentials getSslCredentials() { return this.credentialsConfig.getCredentials(); @@ -142,4 +203,5 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { public SslCredentials getTrustSslCredentials() { return this.trustCredentialsConfig.getCredentials(); } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index c4fb504864..b72cf62d99 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -32,7 +32,9 @@ import org.eclipse.leshan.server.californium.LwM2mPskStore; import org.eclipse.leshan.server.californium.endpoint.CaliforniumServerEndpointsProvider; import org.eclipse.leshan.server.californium.endpoint.coap.CoapServerProtocolProvider; import org.eclipse.leshan.server.californium.endpoint.coaps.CoapsServerProtocolProvider; +import org.eclipse.leshan.server.endpoint.LwM2mServerEndpointsProvider; import org.eclipse.leshan.server.registration.RegistrationStore; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.context.annotation.DependsOn; import org.springframework.stereotype.Component; import org.thingsboard.server.cache.ota.OtaPackageDataCache; @@ -68,7 +70,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se @DependsOn({"lwM2mDownlinkMsgHandler", "lwM2mUplinkMsgHandler"}) @TbLwM2mTransportComponent @RequiredArgsConstructor -public class DefaultLwM2mTransportService implements LwM2MTransportService { +public class DefaultLwM2mTransportService implements LwM2MTransportService, SmartInitializingSingleton { public static final CipherSuite[] RPK_OR_X509_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256}; public static final CipherSuite[] PSK_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256}; @@ -83,7 +85,21 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { private final TbLwM2MAuthorizer authorizer; private final LwM2mVersionedModelProvider modelProvider; - private LeshanServer server; + private volatile LeshanServer server; + private volatile LwM2mServerListener serverListener; + + @Override + public void afterSingletonsInstantiated() { + config.registerServerReloadCallback(() -> { + try { + log.info("LwM2M certificates reloaded. Recreating LwM2M server..."); + recreateLwM2mServer(); + log.info("LwM2M server recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate LwM2M server after certificate reload", e); + } + }); + } @AfterStartUp(order = AfterStartUp.AFTER_TRANSPORT_SERVICE) public void init() { @@ -95,11 +111,11 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { private void startLhServer() { log.info("Starting LwM2M transport server..."); this.server.start(); - LwM2mServerListener lhServerCertListener = new LwM2mServerListener(handler); - this.server.getRegistrationService().addListener(lhServerCertListener.registrationListener); - this.server.getPresenceService().addListener(lhServerCertListener.presenceListener); - this.server.getObservationService().addListener(lhServerCertListener.observationListener); - this.server.getSendService().addListener(lhServerCertListener.sendListener); + serverListener = new LwM2mServerListener(handler); + this.server.getRegistrationService().addListener(serverListener.registrationListener); + this.server.getPresenceService().addListener(serverListener.presenceListener); + this.server.getObservationService().addListener(serverListener.observationListener); + this.server.getSendService().addListener(serverListener.sendListener); log.info("Started LwM2M transport server."); } @@ -214,6 +230,82 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { } } + private synchronized void recreateLwM2mServer() { + LeshanServer oldServer = this.server; + LwM2mServerListener oldListener = this.serverListener; + + log.info("Creating new LwM2M server with updated certificates..."); + LeshanServer newServer = getLhServer(); + + // Only cycle the endpoint providers (CoAP/DTLS). The RegistrationStore and SecurityStore are + // Spring singletons shared with newServer — calling oldServer.stop()/destroy() would propagate + // to them (LeshanServer.stop/destroy propagate to Stoppable/Destroyable stores), which would + // shut down the shared schedulers (TbInMemoryRegistrationStore.destroy calls schedExecutor.shutdownNow), + // killing newServer's cleaner tasks. Leaving the stores running preserves existing device + // registrations across the swap — clients only need to re-establish DTLS on next uplink. + if (oldServer != null) { + log.info("Stopping old LwM2M endpoints to release ports..."); + if (oldListener != null) { + oldServer.getRegistrationService().removeListener(oldListener.registrationListener); + oldServer.getPresenceService().removeListener(oldListener.presenceListener); + oldServer.getObservationService().removeListener(oldListener.observationListener); + oldServer.getSendService().removeListener(oldListener.sendListener); + } + stopEndpoints(oldServer); + } + + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M server", e); + destroyEndpoints(newServer); + // Attempt to restart the old endpoints (shared stores are still running). + if (oldServer != null) { + try { + startEndpoints(oldServer); + if (oldListener != null) { + oldServer.getRegistrationService().addListener(oldListener.registrationListener); + oldServer.getPresenceService().addListener(oldListener.presenceListener); + oldServer.getObservationService().addListener(oldListener.observationListener); + oldServer.getSendService().addListener(oldListener.sendListener); + } + log.info("Restored old LwM2M endpoints successfully."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M endpoints", restoreEx); + } + } + throw e; + } + + LwM2mServerListener newListener = new LwM2mServerListener(handler); + newServer.getRegistrationService().addListener(newListener.registrationListener); + newServer.getPresenceService().addListener(newListener.presenceListener); + newServer.getObservationService().addListener(newListener.observationListener); + newServer.getSendService().addListener(newListener.sendListener); + + this.server = newServer; + this.context.setServer(newServer); + this.serverListener = newListener; + log.info("New LwM2M server started with refreshed certificates. Existing device registrations preserved; clients will re-establish DTLS on next uplink."); + + // Destroy old endpoints only — leave the shared stores alone. + if (oldServer != null) { + destroyEndpoints(oldServer); + } + } + + private void stopEndpoints(LeshanServer server) { + server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::stop); + } + + private void startEndpoints(LeshanServer server) { + server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::start); + } + + private void destroyEndpoints(LeshanServer server) { + server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::destroy); + } + @Override public String getName() { return DataConstants.LWM2M_TRANSPORT_NAME; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java index 8f638cfa2a..c5b8779a3f 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java @@ -50,6 +50,8 @@ import org.eclipse.leshan.core.request.SimpleDownlinkRequest; import org.eclipse.leshan.core.request.WriteAttributesRequest; import org.eclipse.leshan.core.request.WriteCompositeRequest; import org.eclipse.leshan.core.request.WriteRequest; +import org.eclipse.leshan.core.request.argument.Arguments; +import org.eclipse.leshan.core.request.argument.InvalidArgumentException; import org.eclipse.leshan.core.request.exception.ClientSleepingException; import org.eclipse.leshan.core.request.exception.InvalidRequestException; import org.eclipse.leshan.core.request.exception.TimeoutException; @@ -116,6 +118,7 @@ import static org.eclipse.leshan.core.model.ResourceModel.Type.OPAQUE; import static org.thingsboard.server.common.transport.util.JsonUtils.isBase64; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.convertMultiResourceValuesFromRpcBody; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.createModelsDefault; +import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.equalsResourceTypeGetSimpleName; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.fromVersionedIdToObjectId; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.getVerFromPathIdVerOrId; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.validateVersionedId; @@ -268,29 +271,34 @@ public class DefaultLwM2mDownlinkMsgHandler extends LwM2MExecutorAwareService im validateVersionedId(client, request); LwM2mPath pathIds = new LwM2mPath(fromVersionedIdToObjectId(request.getVersionedId())); ResourceModel resourceModelExecute = client.getResourceModel(request.getVersionedId(), modelProvider); - if (resourceModelExecute == null) { - LwM2mModel model = createModelsDefault(); - if (pathIds.isResource()) { - resourceModelExecute = model.getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); - } + if (resourceModelExecute == null && pathIds.isResource()) { + resourceModelExecute = createModelsDefault().getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); } if (resourceModelExecute == null) { - callback.onValidationError(request.toString(), "ResourceModel with " + request.getVersionedId() + - " is absent in system. Need ddd Lwm2m Model with id=" + pathIds.getObjectId() + " ver=" + - getVerFromPathIdVerOrId(request.getVersionedId()) + " to profile."); - } else if (resourceModelExecute.operations.isExecutable()) { - ExecuteRequest downlink; - if (request.getParams() != null && !resourceModelExecute.multiple) { - downlink = new ExecuteRequest(request.getObjectId(), (String) this.converter.convertValue(request.getParams(), - resourceModelExecute.type, ResourceModel.Type.STRING, new LwM2mPath(request.getObjectId()))); - } else { - downlink = new ExecuteRequest(request.getObjectId()); + throw new InvalidArgumentException(String.format("ResourceModel with %s is absent in the system. Need to add Model with id= %s ver=%s to profile.", + request.getVersionedId(), pathIds.getObjectId(), getVerFromPathIdVerOrId(request.getVersionedId()))); + } + if (!resourceModelExecute.operations.isExecutable()) { + throw new InvalidArgumentException(String.format("Resource with %s is not executable.", request.getVersionedId())); + } + + ExecuteRequest downlink; + Object params = request.getParams(); + // 4. Handle parameters if they exist and the resource is not a multiple-instance resource + if (params != null && !resourceModelExecute.multiple) { + ResourceModel.Type resourceModelType = equalsResourceTypeGetSimpleName(params); + if (resourceModelType == null) { + throw new InvalidArgumentException(String.format("Unsupported parameter type: %s. Only simple types (String, Integer, Boolean, etc.) are allowed for Execute arguments.", + params.getClass().getSimpleName())); } - sendSimpleRequest(client, downlink, request.getTimeout(), callback); + String args = (String) this.converter.convertValue(params, resourceModelType, ResourceModel.Type.STRING, pathIds); + downlink = new ExecuteRequest(request.getObjectId(), args); } else { - callback.onValidationError(request.toString(), "Resource with " + request.getVersionedId() + " is not executable."); + downlink = new ExecuteRequest(request.getObjectId()); } - } catch (InvalidRequestException e) { + sendSimpleRequest(client, downlink, request.getTimeout(), callback); + } catch (Exception e) { + log.error("[{}] Validation failed for Execute request: {}", client.getEndpoint(), e.getMessage()); callback.onValidationError(request.toString(), e.getMessage()); } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java index 6980fb6341..a0cbd4f598 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java @@ -241,7 +241,13 @@ public class DefaultLwM2MRpcRequestHandler implements LwM2MRpcRequestHandler { } private void sendExecuteRequest(LwM2mClient client, TransportProtos.ToDeviceRpcRequestMsg requestMsg, String versionedId) { - TbLwM2MExecuteRequest downlink = TbLwM2MExecuteRequest.builder().versionedId(versionedId).timeout(clientContext.getRequestTimeout(client)).build(); + RpcExecuteRequest requestBody = JacksonUtil.fromString(requestMsg.getParams(), RpcExecuteRequest.class); + Object value = requestBody != null ? requestBody.getValue() : null; + TbLwM2MExecuteRequest downlink = TbLwM2MExecuteRequest.builder() + .versionedId(versionedId) + .params(value) + .timeout(clientContext.getRequestTimeout(client)) + .build(); var mainCallback = new TbLwM2MExecuteCallback(logService, client, versionedId); var rpcCallback = new RpcEmptyResponseCallback<>(transportService, client, requestMsg, mainCallback); downlinkHandler.sendExecuteRequest(client, downlink, rpcCallback); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcExecuteRequest.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcExecuteRequest.java new file mode 100644 index 0000000000..e0360c6f71 --- /dev/null +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcExecuteRequest.java @@ -0,0 +1,27 @@ +/** + * 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.transport.lwm2m.server.rpc; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class RpcExecuteRequest extends LwM2MRpcRequestHeader { + + private Object value; + +} diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java new file mode 100644 index 0000000000..bbcbc921f6 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java @@ -0,0 +1,198 @@ +/** + * 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.transport.lwm2m.bootstrap; + +import org.eclipse.leshan.server.bootstrap.LeshanBootstrapServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2mBootstrapCertificateReloadTest { + + @Mock + private LwM2MTransportServerConfig mockServerConfig; + + @Mock + private LwM2MTransportBootstrapConfig mockBootstrapConfig; + + @Mock + private LwM2MBootstrapSecurityStore mockSecurityStore; + + @Mock + private LwM2MInMemoryBootstrapConfigStore mockConfigStore; + + @Mock + private TransportService mockTransportService; + + @Mock + private TbLwM2MDtlsBootstrapCertificateVerifier mockCertificateVerifier; + + @Mock + private LeshanBootstrapServer mockBootstrapServer; + + @Mock + private SslCredentials mockSslCredentials; + + private LwM2MTransportBootstrapService bootstrapService; + + @BeforeEach + public void setup() { + bootstrapService = new LwM2MTransportBootstrapService( + mockServerConfig, + mockBootstrapConfig, + mockSecurityStore, + mockConfigStore, + mockTransportService, + mockCertificateVerifier + ); + + when(mockBootstrapConfig.getHost()).thenReturn("localhost"); + when(mockBootstrapConfig.getPort()).thenReturn(5687); + when(mockBootstrapConfig.getSecureHost()).thenReturn("localhost"); + when(mockBootstrapConfig.getSecurePort()).thenReturn(5688); + when(mockBootstrapConfig.getSslCredentials()).thenReturn(mockSslCredentials); + when(mockServerConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); + } + + @Test + public void givenInit_whenCalled_thenShouldRegisterCertificateReloadCallback() { + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + bootstrapService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + // Force getLhBootstrapServer() to fail by returning null host (causes InetSocketAddress to throw) + when(mockBootstrapConfig.getHost()).thenReturn(null); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + bootstrapService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // getLhBootstrapServer() will fail due to null host before old server is stopped. + // The old server should NOT be destroyed since the new server was never created. + reloadCallback.run(); + + verify(mockBootstrapServer, never()).stop(); + verify(mockBootstrapServer, never()).destroy(); + assertThat(ReflectionTestUtils.getField(bootstrapService, "server")).isSameAs(mockBootstrapServer); + } + + @Test + public void givenNullServer_whenRecreate_thenShouldNotThrow() { + ReflectionTestUtils.setField(bootstrapService, "server", null); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + bootstrapService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // Should not throw — callback catches exceptions internally + reloadCallback.run(); + } + + @Test + public void givenCertificateUpdate_whenRecreate_thenShouldUseNewCredentials() { + SslCredentials oldCredentials = mockSslCredentials; + SslCredentials newCredentials = mock(SslCredentials.class); + + when(mockBootstrapConfig.getSslCredentials()).thenReturn(oldCredentials).thenReturn(newCredentials); + + SslCredentials firstCall = mockBootstrapConfig.getSslCredentials(); + assertThat(firstCall).isEqualTo(oldCredentials); + + SslCredentials secondCall = mockBootstrapConfig.getSslCredentials(); + assertThat(secondCall).isEqualTo(newCredentials); + + verify(mockBootstrapConfig, times(2)).getSslCredentials(); + } + + @Test + public void givenReloadCallback_whenRegistered_thenShouldRegisterExactlyOne() { + bootstrapService.afterSingletonsInstantiated(); + + verify(mockBootstrapConfig, times(1)).registerServerReloadCallback(any()); + } + + @Test + public void givenReloadCallback_whenNewServerStartFails_thenOldServerRestarted() { + // GIVEN + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); + doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); + + LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); + doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + spyService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // WHEN + reloadCallback.run(); + + // THEN + // Old server is stopped (not destroyed) to release ports + verify(mockBootstrapServer).stop(); + verify(mockBootstrapServer, never()).destroy(); + // The new server fails to start and is destroyed + verify(mockNewServer).destroy(); + // Old server is restarted (not rebuilt from potentially stale credentials) + verify(mockBootstrapServer).start(); + assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); + } + +} diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java new file mode 100644 index 0000000000..93f305a190 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java @@ -0,0 +1,106 @@ +/** + * 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.transport.lwm2m.config; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import java.util.concurrent.atomic.AtomicInteger; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@ExtendWith(MockitoExtension.class) +public class LwM2MTransportServerConfigDebounceTest { + + private static final long DEBOUNCE_SECONDS = (long) ReflectionTestUtils.getField(LwM2MTransportServerConfig.class, "RELOAD_DEBOUNCE_SECONDS"); + + @Mock + private SslCredentialsConfig credentialsConfig; + + @Mock + private SslCredentialsConfig trustCredentialsConfig; + + private LwM2MTransportServerConfig config; + + @BeforeEach + public void setup() { + config = new LwM2MTransportServerConfig(); + ReflectionTestUtils.setField(config, "credentialsConfig", credentialsConfig); + ReflectionTestUtils.setField(config, "trustCredentialsConfig", trustCredentialsConfig); + } + + @AfterEach + public void teardown() { + config.destroy(); + } + + @Test + public void givenSingleTrigger_whenScheduleServerReload_thenCallbackFiresOnce() { + AtomicInteger callCount = new AtomicInteger(0); + config.registerServerReloadCallback(callCount::incrementAndGet); + + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + } + + @Test + public void givenTwoRapidTriggers_whenScheduleServerReload_thenCallbackFiresOnce() { + AtomicInteger callCount = new AtomicInteger(0); + config.registerServerReloadCallback(callCount::incrementAndGet); + + invokeScheduleServerReload(); + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + + // Wait extra to confirm no second invocation + await().during(DEBOUNCE_SECONDS + 1, SECONDS) + .atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + } + + @Test + public void givenTriggersOutsideDebounceWindow_whenScheduleServerReload_thenCallbackFiresTwice() { + AtomicInteger callCount = new AtomicInteger(0); + config.registerServerReloadCallback(callCount::incrementAndGet); + + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(2)); + } + + private void invokeScheduleServerReload() { + ReflectionTestUtils.invokeMethod(config, "scheduleServerReload"); + } + +} diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java new file mode 100644 index 0000000000..93b74447fd --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java @@ -0,0 +1,192 @@ +/** + * 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.transport.lwm2m.server; + +import org.eclipse.leshan.server.LeshanServer; +import org.eclipse.leshan.server.observation.ObservationService; +import org.eclipse.leshan.server.registration.RegistrationService; +import org.eclipse.leshan.server.registration.RegistrationStore; +import org.eclipse.leshan.server.send.SendService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cache.ota.OtaPackageDataCache; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; +import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MAuthorizer; +import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MDtlsCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.server.store.TbSecurityStore; +import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2mServerCertificateReloadTest { + + @Mock + private LwM2mTransportContext mockContext; + + @Mock + private LwM2MTransportServerConfig mockConfig; + + @Mock + private OtaPackageDataCache mockOtaCache; + + @Mock + private LwM2mUplinkMsgHandler mockHandler; + + @Mock + private RegistrationStore mockRegistrationStore; + + @Mock + private TbSecurityStore mockSecurityStore; + + @Mock + private TbLwM2MDtlsCertificateVerifier mockCertificateVerifier; + + @Mock + private TbLwM2MAuthorizer mockAuthorizer; + + @Mock + private LwM2mVersionedModelProvider mockModelProvider; + + @Mock + private LeshanServer mockLeshanServer; + + @Mock + private RegistrationService mockRegistrationService; + + @Mock + private ObservationService mockObservationService; + + @Mock + private SendService mockSendService; + + @Mock + private SslCredentials mockSslCredentials; + + private DefaultLwM2mTransportService lwm2mTransportService; + + @BeforeEach + public void setup() { + lwm2mTransportService = new DefaultLwM2mTransportService( + mockContext, + mockConfig, + mockOtaCache, + mockHandler, + mockRegistrationStore, + mockSecurityStore, + mockCertificateVerifier, + mockAuthorizer, + mockModelProvider + ); + + when(mockConfig.getHost()).thenReturn("localhost"); + when(mockConfig.getPort()).thenReturn(5683); + when(mockConfig.getSecureHost()).thenReturn("localhost"); + when(mockConfig.getSecurePort()).thenReturn(5684); + when(mockConfig.getSslCredentials()).thenReturn(mockSslCredentials); + + when(mockLeshanServer.getRegistrationService()).thenReturn(mockRegistrationService); + when(mockLeshanServer.getObservationService()).thenReturn(mockObservationService); + when(mockLeshanServer.getSendService()).thenReturn(mockSendService); + } + + @Test + public void givenRegisterCertificateReloadCallback_whenInvoked_thenShouldRegisterCallback() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); + + // Force getLhServer() to fail by returning null host (causes InetSocketAddress to throw) + when(mockConfig.getHost()).thenReturn(null); + + // With create-then-swap, the old server should NOT be stopped/destroyed if the new one fails to build. + reloadCallback.run(); + + verify(mockLeshanServer, never()).stop(); + verify(mockLeshanServer, never()).destroy(); + // Old server should still be the active one + assertThat(ReflectionTestUtils.getField(lwm2mTransportService, "server")).isSameAs(mockLeshanServer); + } + + @Test + public void givenServerWithListeners_whenNewServerCreationFails_thenListenersArePreserved() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); + + LwM2mServerListener serverListener = new LwM2mServerListener(mockHandler); + ReflectionTestUtils.setField(lwm2mTransportService, "serverListener", serverListener); + + // Force getLhServer() to fail by returning null host + when(mockConfig.getHost()).thenReturn(null); + + // Invoke the callback — new server creation will fail, old listeners should stay + callbackCaptor.getValue().run(); + + verify(mockRegistrationService, never()).removeListener(any()); + } + + @Test + public void givenMultipleReloadCallbacks_whenInvoked_thenShouldRegisterExactlyOne() { + lwm2mTransportService.afterSingletonsInstantiated(); + + verify(mockConfig, times(1)).registerServerReloadCallback(any()); + } + + @Test + public void givenCertificateReload_whenServerNull_thenShouldNotThrow() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", null); + + // Should not throw - callback catches exceptions internally + callbackCaptor.getValue().run(); + } + +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 1551ef9023..a954d7ae7f 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt; import io.netty.handler.ssl.SslHandler; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -48,7 +49,7 @@ import java.util.concurrent.TimeUnit; @Slf4j @Component("MqttSslHandlerProvider") @TbMqttSslTransportComponent -public class MqttSslHandlerProvider { +public class MqttSslHandlerProvider implements SmartInitializingSingleton { @Value("${transport.mqtt.ssl.protocol}") private String sslProtocol; @@ -66,13 +67,35 @@ public class MqttSslHandlerProvider { @Qualifier("mqttSslCredentials") private SslCredentialsConfig mqttSslCredentialsConfig; - private SSLContext sslContext; + private volatile SSLContext sslContext; + + @Override + public void afterSingletonsInstantiated() { + // Eagerly build the initial context so the handshake path is a lock-free volatile read. + this.sslContext = createSslContext(); + mqttSslCredentialsConfig.registerReloadCallback(() -> { + log.info("MQTT SSL certificates reloaded. Rebuilding SSL context..."); + // Build the new context first; if it fails, the old one stays in place, and + // the exception propagates to CertificateReloadManager's retry/backoff logic. + this.sslContext = createSslContext(); + log.info("MQTT SSL context rebuilt. New connections will use the new certificate."); + }); + } public SslHandler getSslHandler() { - if (sslContext == null) { - sslContext = createSslContext(); + SSLContext ctx = sslContext; + // Defensive lazy init in case afterSingletonsInstantiated hasn't run yet (e.g., test wiring). + // In normal operation ctx is non-null here, so the handshake path is lock-free. + if (ctx == null) { + synchronized (this) { + ctx = sslContext; + if (ctx == null) { + ctx = createSslContext(); + sslContext = ctx; + } + } } - SSLEngine sslEngine = sslContext.createSSLEngine(); + SSLEngine sslEngine = ctx.createSSLEngine(); sslEngine.setUseClientMode(false); sslEngine.setNeedClientAuth(false); sslEngine.setWantClientAuth(true); @@ -98,7 +121,7 @@ public class MqttSslHandlerProvider { sslContext.init(km, tm, null); return sslContext; } catch (Exception e) { - log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); + log.error("Unable to set up SSL context. Reason: {}", e.getMessage(), e); throw new RuntimeException("Failed to get SSL context", e); } } @@ -106,8 +129,8 @@ public class MqttSslHandlerProvider { private TrustManager getX509TrustManager(TrustManagerFactory tmf) throws Exception { X509TrustManager x509Tm = null; for (TrustManager tm : tmf.getTrustManagers()) { - if (tm instanceof X509TrustManager) { - x509Tm = (X509TrustManager) tm; + if (tm instanceof X509TrustManager x509TrustManager) { + x509Tm = x509TrustManager; break; } } @@ -191,5 +214,7 @@ public class MqttSslHandlerProvider { return false; } } + } + } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 10245c5a24..b69b5d5258 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -32,9 +32,6 @@ import org.thingsboard.server.transport.mqtt.gateway.GatewayMetricsService; import java.net.InetSocketAddress; import java.util.concurrent.atomic.AtomicInteger; -/** - * Created by ashvayka on 04.10.18. - */ @Slf4j @Component @TbMqttTransportComponent diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index 8b780c6ab4..4e41fa7eca 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -78,22 +78,47 @@ public class MqttTransportService implements TbTransportService { ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.valueOf(leakDetectorLevel.toUpperCase())); log.info("Starting MQTT transport..."); - bossGroup = new NioEventLoopGroup(bossGroupThreadCount); - workerGroup = new NioEventLoopGroup(workerGroupThreadCount); - ServerBootstrap b = new ServerBootstrap(); - b.group(bossGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .childHandler(new MqttTransportServerInitializer(context, false)) - .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); - - serverChannel = b.bind(host, port).sync().channel(); - if (sslEnabled) { - b = new ServerBootstrap(); + try { + bossGroup = new NioEventLoopGroup(bossGroupThreadCount); + workerGroup = new NioEventLoopGroup(workerGroupThreadCount); + ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) - .childHandler(new MqttTransportServerInitializer(context, true)) + .childHandler(new MqttTransportServerInitializer(context, false)) .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); - sslServerChannel = b.bind(sslHost, sslPort).sync().channel(); + + serverChannel = b.bind(host, port).sync().channel(); + if (sslEnabled) { + b = new ServerBootstrap(); + b.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new MqttTransportServerInitializer(context, true)) + .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); + sslServerChannel = b.bind(sslHost, sslPort).sync().channel(); + } + } catch (Exception e) { + log.error("Failed to start MQTT transport, releasing resources", e); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + try { + if (serverChannel != null) { + serverChannel.close().sync(); + } + if (sslServerChannel != null) { + sslServerChannel.close().sync(); + } + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + if (workerGroup != null) { + workerGroup.shutdownGracefully(); + } + if (bossGroup != null) { + bossGroup.shutdownGracefully(); + } + } + throw e; } log.info("Mqtt transport started!"); } diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java new file mode 100644 index 0000000000..84ef4f2979 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java @@ -0,0 +1,276 @@ +/** + * 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.transport.mqtt; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class MqttSslCertificateReloadIntegrationTest { + + @TempDir + Path tempDir; + + @Mock + private TransportService transportService; + + @Test + public void givenMqttSslProvider_whenCertFileChangedAndReloadTriggered_thenNewConnectionSeesNewCert() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); + + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctxA = getProviderSslContext(provider); + X509Certificate servedA; + try (SSLServerSocket ss = createServerSocket(ctxA)) { + servedA = doHandshakeAndGetServerCert(ss); + } + assertThat(servedA.getSubjectX500Principal()).isEqualTo(certA.getSubjectX500Principal()); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + + credentialsConfig.onCertificateFileChanged(); + + SSLContext ctxB = getProviderSslContext(provider); + assertThat(ctxB).isNotSameAs(ctxA); + X509Certificate servedB; + try (SSLServerSocket ss = createServerSocket(ctxB)) { + servedB = doHandshakeAndGetServerCert(ss); + } + assertThat(servedB.getSubjectX500Principal()).isEqualTo(certB.getSubjectX500Principal()); + assertThat(servedB.getSubjectX500Principal()).isNotEqualTo(servedA.getSubjectX500Principal()); + } + + @Test + public void givenMqttSslProvider_whenReloadCalledWithSameFiles_thenSslContextIsRecreated() throws Exception { + KeyPair keyPair = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(keyPair, "CN=SameCert"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, cert); + writeKeyPem(keyFile, keyPair); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctx1 = getProviderSslContext(provider); + assertThat(ctx1).isNotNull(); + + credentialsConfig.onCertificateFileChanged(); + + SSLContext ctx2 = getProviderSslContext(provider); + assertThat(ctx2).isNotSameAs(ctx1); + + X509Certificate served; + try (SSLServerSocket ss = createServerSocket(ctx2)) { + served = doHandshakeAndGetServerCert(ss); + } + assertThat(served.getSubjectX500Principal()).isEqualTo(cert.getSubjectX500Principal()); + } + + @Test + public void givenMqttSslProvider_whenMultipleReloads_thenEachProducesNewContext() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); + KeyPair keyPairC = generateKeyPair(); + X509Certificate certC = generateSelfSignedCert(keyPairC, "CN=CertC"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctx1 = getProviderSslContext(provider); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + SSLContext ctx2 = getProviderSslContext(provider); + + writeCertPem(certFile, certC); + writeKeyPem(keyFile, keyPairC); + credentialsConfig.onCertificateFileChanged(); + SSLContext ctx3 = getProviderSslContext(provider); + + assertThat(ctx1).isNotSameAs(ctx2); + assertThat(ctx2).isNotSameAs(ctx3); + + X509Certificate served; + try (SSLServerSocket ss = createServerSocket(ctx3)) { + served = doHandshakeAndGetServerCert(ss); + } + assertThat(served.getSubjectX500Principal()).isEqualTo(certC.getSubjectX500Principal()); + } + + private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) throws Exception { + PemSslCredentials pem = new PemSslCredentials(); + pem.setCertFile(certFile.toAbsolutePath().toString()); + pem.setKeyFile(keyFile.toAbsolutePath().toString()); + + SslCredentialsConfig config = new SslCredentialsConfig("MQTT SSL Test", false); + config.setEnabled(true); + config.setType(org.thingsboard.server.common.transport.config.ssl.SslCredentialsType.PEM); + config.setPem(pem); + config.setKeystore(new org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials()); + config.init(); + return config; + } + + private MqttSslHandlerProvider createMqttSslHandlerProvider(SslCredentialsConfig credentialsConfig) { + MqttSslHandlerProvider provider = new MqttSslHandlerProvider(); + ReflectionTestUtils.setField(provider, "sslProtocol", "TLSv1.2"); + ReflectionTestUtils.setField(provider, "mqttSslCredentialsConfig", credentialsConfig); + ReflectionTestUtils.setField(provider, "transportService", transportService); + provider.afterSingletonsInstantiated(); + return provider; + } + + /** + * Triggers SSLContext creation through the provider's getSslHandler() path, + * then extracts the cached SSLContext for direct server socket use. + */ + private SSLContext getProviderSslContext(MqttSslHandlerProvider provider) { + provider.getSslHandler(); + return (SSLContext) ReflectionTestUtils.getField(provider, "sslContext"); + } + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { + X500Name subject = new X500Name(subjectDn); + Date now = new Date(); + Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(System.nanoTime()), now, expiry, + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); + } + + private void writeCertPem(Path path, X509Certificate cert) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + } + + private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + } + } + + private SSLServerSocket createServerSocket(SSLContext ctx) throws Exception { + return (SSLServerSocket) ctx.getServerSocketFactory().createServerSocket(0, 1, InetAddress.getLoopbackAddress()); + } + + private X509Certificate doHandshakeAndGetServerCert(SSLServerSocket serverSocket) throws Exception { + Thread acceptor = new Thread(() -> { + try (var conn = serverSocket.accept()) { + conn.getInputStream().read(); + } catch (Exception ignored) {} + }); + acceptor.setDaemon(true); + acceptor.start(); + + SSLContext clientCtx = SSLContext.getInstance("TLSv1.2"); + clientCtx.init(null, new TrustManager[]{new TrustAllManager()}, null); + + try (SSLSocket client = (SSLSocket) clientCtx.getSocketFactory() + .createSocket(InetAddress.getLoopbackAddress(), serverSocket.getLocalPort())) { + client.setSoTimeout(5000); + client.startHandshake(); + + Certificate[] peerCerts = client.getSession().getPeerCertificates(); + assertThat(peerCerts).isNotEmpty(); + return (X509Certificate) peerCerts[0]; + } finally { + acceptor.join(5000); + } + } + + private static class TrustAllManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + } + +} diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java new file mode 100644 index 0000000000..9b4ce007f8 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java @@ -0,0 +1,197 @@ +/** + * 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.transport.mqtt; + +import io.netty.handler.ssl.SslHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class MqttSslHandlerProviderTest { + + @Mock + private SslCredentialsConfig mockCredentialsConfig; + + @Mock + private SslCredentials mockCredentials; + + @Mock + private TransportService mockTransportService; + + private MqttSslHandlerProvider sslHandlerProvider; + + @BeforeEach + public void setup() throws Exception { + sslHandlerProvider = new MqttSslHandlerProvider(); + ReflectionTestUtils.setField(sslHandlerProvider, "mqttSslCredentialsConfig", mockCredentialsConfig); + ReflectionTestUtils.setField(sslHandlerProvider, "transportService", mockTransportService); + ReflectionTestUtils.setField(sslHandlerProvider, "sslProtocol", "TLSv1.2"); + + KeyManagerFactory mockKmf = mock(KeyManagerFactory.class); + TrustManagerFactory mockTmf = mock(TrustManagerFactory.class); + X509TrustManager mockTrustManager = mock(X509TrustManager.class); + + when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); + when(mockCredentials.createKeyManagerFactory()).thenReturn(mockKmf); + when(mockCredentials.createTrustManagerFactory()).thenReturn(mockTmf); + when(mockKmf.getKeyManagers()).thenReturn(new KeyManager[0]); + when(mockTmf.getTrustManagers()).thenReturn(new TrustManager[]{mockTrustManager}); + } + + @Test + public void givenInitialized_whenGetSslHandler_thenShouldCreateSSLContext() { + sslHandlerProvider.afterSingletonsInstantiated(); + + SslHandler handler1 = sslHandlerProvider.getSslHandler(); + SslHandler handler2 = sslHandlerProvider.getSslHandler(); + + assertThat(handler1).isNotNull(); + assertThat(handler2).isNotNull(); + assertThat(handler1).isNotSameAs(handler2); + + SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context).isNotNull(); + } + + @Test + public void givenCertificatesReloaded_whenReloadCallbackInvoked_thenShouldRebuildSSLContextEagerly() { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(initialContext).isNotNull(); + + reloadCallback.run(); + + // After reload the context is rebuilt eagerly (no null-invalidation), so handshakes stay lock-free. + SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfterReload).isNotNull(); + assertThat(contextAfterReload).isNotSameAs(initialContext); + + SslHandler handler = sslHandlerProvider.getSslHandler(); + assertThat(handler).isNotNull(); + } + + @Test + public void givenConcurrentGetSslHandlerCalls_whenContextAlreadyBuilt_thenAllReadsReturnSameContext() throws Exception { + sslHandlerProvider.afterSingletonsInstantiated(); + + SSLContext contextBefore = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextBefore).isNotNull(); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + List handlers = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + handlers.add(sslHandlerProvider.getSslHandler()); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(handlers).hasSize(5).allSatisfy(h -> assertThat(h).isNotNull()); + // Concurrent handshakes read the same pre-built context without the old sync bottleneck. + SSLContext contextAfter = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfter).isSameAs(contextBefore); + } + + @Test + public void givenReloadCallback_whenInvoked_thenShouldSwapSSLContextEagerly() { + sslHandlerProvider.afterSingletonsInstantiated(); + + sslHandlerProvider.getSslHandler(); + SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(initialContext).isNotNull(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfterReload).isNotNull(); + assertThat(contextAfterReload).isNotSameAs(initialContext); + } + + @Test + public void givenMultipleReloads_whenGetSslHandler_thenShouldRecreateEachTime() { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + SSLContext context1; + SSLContext context2; + SSLContext context3; + + sslHandlerProvider.getSslHandler(); + context1 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context1).isNotNull(); + + reloadCallback.run(); + sslHandlerProvider.getSslHandler(); + context2 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context2).isNotNull(); + assertThat(context2).isNotSameAs(context1); + + reloadCallback.run(); + sslHandlerProvider.getSslHandler(); + context3 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context3).isNotNull(); + assertThat(context3).isNotSameAs(context2); + assertThat(context3).isNotSameAs(context1); + } + +} diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java new file mode 100644 index 0000000000..ab21209a48 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java @@ -0,0 +1,111 @@ +/** + * 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.transport.mqtt; + +import io.netty.channel.Channel; +import io.netty.channel.EventLoopGroup; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.BindException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.mock; + +public class MqttTransportServiceTest { + + private static final String HOST = "127.0.0.1"; + + private MqttTransportService service; + private ServerSocket occupiedSocket; + private int occupiedPort; + + @BeforeEach + public void setUp() throws Exception { + occupiedSocket = new ServerSocket(0, 50, InetAddress.getByName(HOST)); + occupiedPort = occupiedSocket.getLocalPort(); + + service = new MqttTransportService(); + ReflectionTestUtils.setField(service, "host", HOST); + ReflectionTestUtils.setField(service, "port", occupiedPort); + ReflectionTestUtils.setField(service, "sslEnabled", false); + ReflectionTestUtils.setField(service, "sslHost", HOST); + ReflectionTestUtils.setField(service, "sslPort", 0); + ReflectionTestUtils.setField(service, "leakDetectorLevel", "DISABLED"); + ReflectionTestUtils.setField(service, "bossGroupThreadCount", 1); + ReflectionTestUtils.setField(service, "workerGroupThreadCount", 1); + ReflectionTestUtils.setField(service, "keepAlive", true); + ReflectionTestUtils.setField(service, "context", mock(MqttTransportContext.class)); + } + + @AfterEach + public void tearDown() throws Exception { + if (occupiedSocket != null && !occupiedSocket.isClosed()) { + occupiedSocket.close(); + } + } + + @Test + public void whenPlainBindFails_thenInitThrowsAndReleasesNettyResources() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(BindException.class); + + EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup"); + EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup"); + + assertThat(boss).isNotNull(); + assertThat(worker).isNotNull(); + assertThat(boss.isShuttingDown()).isTrue(); + assertThat(worker.isShuttingDown()).isTrue(); + + await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated); + await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated); + } + + @Test + public void whenSslBindFailsAfterPlainBound_thenInitThrowsAndClosesPlainChannelAndReleasesNettyResources() { + ReflectionTestUtils.setField(service, "port", 0); + ReflectionTestUtils.setField(service, "sslEnabled", true); + ReflectionTestUtils.setField(service, "sslPort", occupiedPort); + + assertThatThrownBy(() -> service.init()) + .isInstanceOf(BindException.class); + + Channel serverChannel = (Channel) ReflectionTestUtils.getField(service, "serverChannel"); + Channel sslServerChannel = (Channel) ReflectionTestUtils.getField(service, "sslServerChannel"); + EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup"); + EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup"); + + assertThat(serverChannel).isNotNull(); + assertThat(sslServerChannel).isNull(); + assertThat(boss).isNotNull(); + assertThat(worker).isNotNull(); + + await().atMost(10, TimeUnit.SECONDS).until(() -> !serverChannel.isOpen()); + + assertThat(boss.isShuttingDown()).isTrue(); + assertThat(worker.isShuttingDown()).isTrue(); + await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated); + await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated); + } +} diff --git a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java index 7d92639f1a..af5e1f04b2 100644 --- a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java +++ b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java @@ -160,7 +160,6 @@ public class SnmpTransportContext extends TransportContext { return; } sessions.put(device.getId(), sessionContext); - snmpTransportService.createQueryingTasks(sessionContext); log.info("Established SNMP device session for device {}", device.getId()); } @@ -224,6 +223,8 @@ public class SnmpTransportContext extends TransportContext { registerTransportSession(sessionContext, msg); }); transportService.lifecycleEvent(sessionContext.getTenantId(), sessionContext.getDeviceId(), ComponentLifecycleEvent.STARTED, true, null); + snmpTransportService.createQueryingTasks(sessionContext); + log.info("[{}] Session registered and querying tasks created", sessionContext.getDeviceId()); } else { log.warn("[{}] Failed to process device auth", sessionContext.getDeviceId()); } diff --git a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java index 0c39b7e740..a15a21b6e3 100644 --- a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java +++ b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java @@ -164,7 +164,7 @@ public class SnmpTransportService implements TbTransportService, CommandResponde ScheduledTask scheduledTask = new ScheduledTask(); scheduledTask.init(() -> { try { - if (sessionContext.isActive()) { + if (sessionContext.isActive() && sessionContext.isConnected()) { return sendRequest(sessionContext, repeatingCommunicationConfig); } } catch (Exception e) { @@ -390,7 +390,11 @@ public class SnmpTransportService implements TbTransportService, CommandResponde JsonObject responseData = responseDataMappers.get(requestContext.getCommunicationSpec()).map(response, requestContext); if (responseData.size() == 0) { - log.warn("[{}] No values in the response", sessionContext.getDeviceId()); + log.warn("[{}] No values in the response for spec {}. Response PDUs count: {}, Mappings count: {}", + sessionContext.getDeviceId(), requestContext.getCommunicationSpec(), + response.size(), requestContext.getResponseMappings().size()); + log.debug("[{}] No values in the response for spec {}. Response PDUs: {}", + sessionContext.getDeviceId(), requestContext.getCommunicationSpec(), response); throw new IllegalArgumentException("No values in the response"); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java index df30e2879b..c595e3ca83 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java @@ -19,9 +19,13 @@ import lombok.Getter; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.queue.discovery.event.TbApplicationEvent; +import java.io.Serial; + public final class DeviceDeletedEvent extends TbApplicationEvent { + @Serial private static final long serialVersionUID = -7453664970966733857L; + @Getter private final DeviceId deviceId; @@ -29,4 +33,5 @@ public final class DeviceDeletedEvent extends TbApplicationEvent { super(new Object()); this.deviceId = deviceId; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java index 1e03156ec8..12857e3208 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java @@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.UplinkNotificationMs import java.util.Optional; import java.util.UUID; -/** - * Created by ashvayka on 04.10.18. - */ public interface SessionMsgListener { void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java index 9317652719..afd7bd2fed 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java @@ -30,9 +30,6 @@ import org.thingsboard.server.queue.scheduler.SchedulerComponent; import java.util.concurrent.ExecutorService; -/** - * Created by ashvayka on 15.10.18. - */ @Slf4j @Data public abstract class TransportContext { @@ -77,6 +74,4 @@ public abstract class TransportContext { return serviceInfoProvider.getServiceId(); } - - } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index a8f8a8b863..61c22d84c0 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -66,9 +66,6 @@ import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicInteger; -/** - * Created by ashvayka on 04.10.18. - */ public interface TransportService { GetEntityProfileResponseMsg getEntityProfile(GetEntityProfileRequestMsg msg); @@ -162,4 +159,5 @@ public interface TransportService { boolean hasSession(SessionInfoProto sessionInfo); void createGaugeStats(String statsName, AtomicInteger number, String... tags); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java index 41cb57da04..82849b5bdd 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.common.transport; -/** - * Created by ashvayka on 04.10.18. - */ public interface TransportServiceCallback { TransportServiceCallback EMPTY = new TransportServiceCallback() { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java index f15fc42364..55e6f63e5d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java @@ -37,41 +37,55 @@ import java.util.Enumeration; import java.util.HashSet; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; public abstract class AbstractSslCredentials implements SslCredentials { - private char[] keyPasswordArray; + private record SslState( + char[] keyPasswordArray, + KeyStore keyStore, + PrivateKey privateKey, + PublicKey publicKey, + X509Certificate[] chain, + X509Certificate[] trusts + ) {} - private KeyStore keyStore; - - private PrivateKey privateKey; - - private PublicKey publicKey; - - private X509Certificate[] chain; - - private X509Certificate[] trusts; + private final AtomicReference state = new AtomicReference<>(); @Override public void init(boolean trustsOnly) throws IOException, GeneralSecurityException { + SslState newState = buildState(trustsOnly); + state.set(newState); + } + + @Override + public void reload(boolean trustsOnly) throws IOException, GeneralSecurityException { + init(trustsOnly); + } + + private SslState buildState(boolean trustsOnly) throws IOException, GeneralSecurityException { String keyPassword = getKeyPassword(); + char[] keyPasswordArray; if (StringUtils.isEmpty(keyPassword)) { - this.keyPasswordArray = new char[0]; + keyPasswordArray = new char[0]; } else { - this.keyPasswordArray = keyPassword.toCharArray(); + keyPasswordArray = keyPassword.toCharArray(); } - this.keyStore = this.loadKeyStore(trustsOnly, this.keyPasswordArray); - Set trustedCerts = getTrustedCerts(this.keyStore, trustsOnly); - this.trusts = trustedCerts.toArray(new X509Certificate[0]); + KeyStore keyStore = this.loadKeyStore(trustsOnly, keyPasswordArray); + Set trustedCerts = getTrustedCerts(keyStore, trustsOnly); + X509Certificate[] trusts = trustedCerts.toArray(new X509Certificate[0]); + PrivateKey privateKey = null; + PublicKey publicKey = null; + X509Certificate[] chain = null; if (!trustsOnly) { PrivateKeyEntry privateKeyEntry = null; String keyAlias = this.getKeyAlias(); if (!StringUtils.isEmpty(keyAlias)) { - privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, keyAlias, this.keyPasswordArray); + privateKeyEntry = tryGetPrivateKeyEntry(keyStore, keyAlias, keyPasswordArray); } else { - for (Enumeration e = this.keyStore.aliases(); e.hasMoreElements(); ) { + for (Enumeration e = keyStore.aliases(); e.hasMoreElements(); ) { String alias = e.nextElement(); - privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, alias, this.keyPasswordArray); + privateKeyEntry = tryGetPrivateKeyEntry(keyStore, alias, keyPasswordArray); if (privateKeyEntry != null) { this.updateKeyAlias(alias); break; @@ -82,50 +96,61 @@ public abstract class AbstractSslCredentials implements SslCredentials { throw new IllegalArgumentException("Failed to get private key from the keystore or pem files. " + "Please check if the private key exists in the keystore or pem files and if the provided private key password is valid."); } - this.chain = asX509Certificates(privateKeyEntry.getCertificateChain()); - this.privateKey = privateKeyEntry.getPrivateKey(); - if (this.chain.length > 0) { - this.publicKey = this.chain[0].getPublicKey(); + chain = asX509Certificates(privateKeyEntry.getCertificateChain()); + privateKey = privateKeyEntry.getPrivateKey(); + if (chain.length > 0) { + publicKey = chain[0].getPublicKey(); } } + return new SslState(keyPasswordArray, keyStore, privateKey, publicKey, chain, trusts); + } + + private SslState getState() { + SslState s = state.get(); + if (s == null) { + throw new IllegalStateException("SSL credentials not initialized. Call init() first."); + } + return s; } @Override public KeyStore getKeyStore() { - return this.keyStore; + return getState().keyStore; } @Override public PrivateKey getPrivateKey() { - return this.privateKey; + return getState().privateKey; } @Override public PublicKey getPublicKey() { - return this.publicKey; + return getState().publicKey; } @Override public X509Certificate[] getCertificateChain() { - return this.chain; + return getState().chain; } @Override public X509Certificate[] getTrustedCertificates() { - return this.trusts; + return getState().trusts; } @Override public TrustManagerFactory createTrustManagerFactory() throws NoSuchAlgorithmException, KeyStoreException { + SslState s = getState(); TrustManagerFactory tmFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmFactory.init(this.keyStore); + tmFactory.init(s.keyStore); return tmFactory; } @Override public KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException { + SslState s = getState(); KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(this.keyStore, this.keyPasswordArray); + kmf.init(s.keyStore, s.keyPasswordArray); return kmf; } @@ -133,7 +158,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { public String getValueFromSubjectNameByKey(String subjectName, String key) { String[] dns = subjectName.split(","); Optional cn = (Arrays.stream(dns).filter(dn -> dn.contains(key + "="))).findFirst(); - String value = cn.isPresent() ? cn.get().replace(key + "=", "") : null; + String value = cn.map(s -> s.replace(key + "=", "")).orElse(null); return StringUtils.isNotEmpty(value) ? value : null; } @@ -189,7 +214,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { if (cert instanceof X509Certificate) { if (trustsOnly) { // is CA certificate - if (((X509Certificate) cert).getBasicConstraints()>=0) { + if (((X509Certificate) cert).getBasicConstraints() >= 0) { set.add((X509Certificate) cert); } } else { @@ -203,12 +228,12 @@ public abstract class AbstractSslCredentials implements SslCredentials { if (trustsOnly) { for (Certificate cert : certs) { // is CA certificate - if (((X509Certificate) cert).getBasicConstraints()>=0) { + if (((X509Certificate) cert).getBasicConstraints() >= 0) { set.add((X509Certificate) cert); } } } else { - set.add((X509Certificate)certs[0]); + set.add((X509Certificate) certs[0]); } } } @@ -216,4 +241,5 @@ public abstract class AbstractSslCredentials implements SslCredentials { } catch (KeyStoreException ignored) {} return Collections.unmodifiableSet(set); } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java index 33851f50b1..3986704a33 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java @@ -22,8 +22,11 @@ import org.thingsboard.server.common.data.StringUtils; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.util.Collections; +import java.util.List; @Data @EqualsAndHashCode(callSuper = true) @@ -54,4 +57,15 @@ public class KeystoreSslCredentials extends AbstractSslCredentials { protected void updateKeyAlias(String keyAlias) { this.keyAlias = keyAlias; } + + @Override + public List getCertificateFilePaths() { + if (!StringUtils.isEmpty(storeFile) && !storeFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + // Include the path even if the file doesn't exist yet — the watcher uses mtime=0 / checksum="" as + // baseline, so a late-appearing file (e.g., mounted after boot) will be detected and trigger a reload. + return Collections.singletonList(Path.of(storeFile).toAbsolutePath()); + } + return Collections.emptyList(); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java index cb2c9ba97b..fb2e5a4a0d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.StringUtils; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.PrivateKey; @@ -76,13 +77,13 @@ public class PemSslCredentials extends AbstractSslCredentials { if (object instanceof X509CertificateHolder) { X509Certificate x509Cert = certConverter.getCertificate((X509CertificateHolder) object); certificates.add(x509Cert); - } else if (object instanceof PEMEncryptedKeyPair) { + } else if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) { PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray); - privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate(); - } else if (object instanceof PEMKeyPair) { - privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate(); - } else if (object instanceof PrivateKeyInfo) { - privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object); + privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate(); + } else if (object instanceof PEMKeyPair pemKeyPair) { + privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate(); + } else if (object instanceof PrivateKeyInfo privateKeyInfo) { + privateKey = keyConverter.getPrivateKey(privateKeyInfo); } } } @@ -93,15 +94,15 @@ public class PemSslCredentials extends AbstractSslCredentials { try (PEMParser pemParser = new PEMParser(new InputStreamReader(inStream))) { Object object; while ((object = pemParser.readObject()) != null) { - if (object instanceof PEMEncryptedKeyPair) { + if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) { PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray); - privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate(); + privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate(); break; - } else if (object instanceof PEMKeyPair) { - privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate(); + } else if (object instanceof PEMKeyPair pemKeyPair) { + privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate(); break; - } else if (object instanceof PrivateKeyInfo) { - privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object); + } else if (object instanceof PrivateKeyInfo privateKeyInfo) { + privateKey = keyConverter.getPrivateKey(privateKeyInfo); } } } @@ -138,6 +139,22 @@ public class PemSslCredentials extends AbstractSslCredentials { } @Override - protected void updateKeyAlias(String keyAlias) { + protected void updateKeyAlias(String keyAlias) {} + + @Override + public List getCertificateFilePaths() { + List paths = new ArrayList<>(); + addIfFileSystemPath(paths, certFile); + addIfFileSystemPath(paths, keyFile); + return paths; + } + + private static void addIfFileSystemPath(List paths, String filePath) { + if (!StringUtils.isEmpty(filePath) && !filePath.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + // Include the path even if the file doesn't exist yet — the watcher uses mtime=0 / checksum="" as + // baseline, so a late-appearing file (e.g. mounted after boot) will be detected and trigger a reload. + paths.add(Path.of(filePath).toAbsolutePath()); + } } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java index 89d4540b77..89dccac18e 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.transport.config.ssl; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.TrustManagerFactory; import java.io.IOException; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.KeyStoreException; @@ -26,11 +27,14 @@ import java.security.PrivateKey; import java.security.PublicKey; import java.security.UnrecoverableKeyException; import java.security.cert.X509Certificate; +import java.util.List; public interface SslCredentials { void init(boolean trustsOnly) throws IOException, GeneralSecurityException; + void reload(boolean trustsOnly) throws IOException, GeneralSecurityException; + KeyStore getKeyStore(); String getKeyPassword(); @@ -50,4 +54,7 @@ public interface SslCredentials { KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException; String getValueFromSubjectNameByKey(String subjectName, String key); + + List getCertificateFilePaths(); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java index 0df22a33cb..e747de4931 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java @@ -19,6 +19,9 @@ import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + @Slf4j @Data public class SslCredentialsConfig { @@ -33,6 +36,8 @@ public class SslCredentialsConfig { private final String name; private final boolean trustsOnly; + private final List reloadCallbacks = new CopyOnWriteArrayList<>(); + public SslCredentialsConfig(String name, boolean trustsOnly) { this.name = name; this.trustsOnly = trustsOnly; @@ -62,4 +67,29 @@ public class SslCredentialsConfig { } } + public void onCertificateFileChanged() { + log.info("{}: Certificate file changed. Reloading SSL credentials...", name); + try { + this.credentials.reload(this.trustsOnly); + } catch (Exception e) { + log.error("{}: Failed to reload SSL credentials", name, e); + // Rethrow, so CertificateReloadManager's watcher counts this as a failure + // and applies MAX_CONSECUTIVE_FAILURES backoff instead of treating it as a successful reload. + throw new RuntimeException(name + ": Failed to reload SSL credentials", e); + } + log.info("{}: SSL credentials reloaded successfully.", name); + + for (Runnable callback : reloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("{}: Error executing reload callback", name, e); + } + } + } + + public void registerReloadCallback(Runnable callback) { + this.reloadCallbacks.add(callback); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index 34cc1151c4..2a291b3888 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java @@ -15,11 +15,14 @@ */ package org.thingsboard.server.common.transport.config.ssl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.ssl.NoSuchSslBundleException; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.ssl.SslStoreBundle; @@ -30,71 +33,124 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BiConsumer; import java.util.function.Consumer; +@Slf4j @Component @ConditionalOnExpression("'${spring.main.web-environment:true}'=='true' && '${server.ssl.enabled:false}'=='true'") -public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer { +public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer, SmartInitializingSingleton { - @Bean - @ConfigurationProperties(prefix = "server.ssl.credentials") - public SslCredentialsConfig httpServerSslCredentials() { - return new SslCredentialsConfig("HTTP Server SSL Credentials", false); - } + private static final String DEFAULT_BUNDLE_NAME = "default"; + + private final ServerProperties serverProperties; + private final List> updateHandlers = new CopyOnWriteArrayList<>(); @Autowired @Qualifier("httpServerSslCredentials") private SslCredentialsConfig httpServerSslCredentialsConfig; @Autowired - SslBundles sslBundles; - - private final ServerProperties serverProperties; + private SslBundles sslBundles; public SslCredentialsWebServerCustomizer(ServerProperties serverProperties) { this.serverProperties = serverProperties; } + @Bean + @ConfigurationProperties(prefix = "server.ssl.credentials") + public SslCredentialsConfig httpServerSslCredentials() { + return new SslCredentialsConfig("HTTP Server SSL Credentials", false); + } + + @Bean + public SslBundles sslBundles() { + return new DynamicSslBundles(); + } + @Override public void customize(ConfigurableServletWebServerFactory factory) { - SslCredentials sslCredentials = this.httpServerSslCredentialsConfig.getCredentials(); + SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials(); + Ssl ssl = serverProperties.getSsl(); - ssl.setBundle("default"); - ssl.setKeyAlias(sslCredentials.getKeyAlias()); - ssl.setKeyPassword(sslCredentials.getKeyPassword()); + ssl.setBundle(DEFAULT_BUNDLE_NAME); + ssl.setKeyAlias(credentials.getKeyAlias()); + ssl.setKeyPassword(credentials.getKeyPassword()); + factory.setSsl(ssl); factory.setSslBundles(sslBundles); } - @Bean - public SslBundles sslBundles() { + @Override + public void afterSingletonsInstantiated() { + httpServerSslCredentialsConfig.registerReloadCallback(this::reloadSslCertificates); + } + + private void reloadSslCertificates() { + try { + log.info("Reloading HTTP Server SSL certificates..."); + + SslBundle newBundle = createSslBundle(); + notifyUpdateHandlers(newBundle); + + log.info("HTTP Server SSL certificates reloaded successfully"); + } catch (Exception e) { + log.error("Failed to reload HTTP Server SSL certificates", e); + } + } + + private SslBundle createSslBundle() { + SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials(); + SslStoreBundle storeBundle = SslStoreBundle.of( - httpServerSslCredentialsConfig.getCredentials().getKeyStore(), - httpServerSslCredentialsConfig.getCredentials().getKeyPassword(), + credentials.getKeyStore(), + credentials.getKeyPassword(), null ); - return new SslBundles() { - @Override - public SslBundle getBundle(String name) { - return SslBundle.of(storeBundle); - } + return SslBundle.of(storeBundle); + } - @Override - public List getBundleNames() { - return List.of("default"); + private void notifyUpdateHandlers(SslBundle newBundle) { + for (Consumer handler : updateHandlers) { + try { + handler.accept(newBundle); + } catch (Exception e) { + log.error("Failed to notify SSL bundle update handler", e); } + } + } - @Override - public void addBundleUpdateHandler(String name, Consumer handler) { - // no-op + private class DynamicSslBundles implements SslBundles { + + @Override + public SslBundle getBundle(String name) { + if (!DEFAULT_BUNDLE_NAME.equals(name)) { + throw new NoSuchSslBundleException(name, "Unknown SSL bundle: " + name); } + return createSslBundle(); + } + + @Override + public List getBundleNames() { + return List.of(DEFAULT_BUNDLE_NAME); + } - @Override - public void addBundleRegisterHandler(BiConsumer handler) { - // no-op + @Override + public void addBundleUpdateHandler(String name, Consumer handler) { + if (DEFAULT_BUNDLE_NAME.equals(name)) { + updateHandlers.add(handler); + log.debug("Registered SSL bundle update handler for bundle: {}", name); + } else { + log.warn("Attempted to register update handler for unknown bundle: {}", name); } - }; + } + + @Override + public void addBundleRegisterHandler(BiConsumer registerHandler) { + log.debug("addBundleRegisterHandler is not supported for dynamic SSL bundles"); + } + } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java new file mode 100644 index 0000000000..63f2247aba --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -0,0 +1,299 @@ +/** + * 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.transport.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import org.thingsboard.server.queue.util.TbTransportComponent; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@TbTransportComponent +public class CertificateReloadManager implements SmartInitializingSingleton, DisposableBean { + + private static final int MAX_CONSECUTIVE_FAILURES = 10; + + @Value("${transport.ssl.certificate.reload.enabled:true}") + private boolean reloadEnabled; + + @Value("${transport.ssl.certificate.reload.check_interval_seconds:60}") + private long checkIntervalInSeconds; + + @Autowired + protected ApplicationContext applicationContext; + + private final Map watchers = new ConcurrentHashMap<>(); + private volatile ScheduledExecutorService scheduler; + + public void registerWatcher(String name, Path certPath, Runnable reloadCallback) { + registerWatcher(name, List.of(certPath), reloadCallback); + } + + public void registerWatcher(String name, List certPaths, Runnable reloadCallback) { + watchers.put(name, new CertificateWatcher(certPaths, reloadCallback)); + log.info("Registered certificate watcher for: {} (watching {} file(s))", name, certPaths.size()); + } + + private void checkCertificates() { + watchers.forEach((name, watcher) -> { + try { + watcher.checkAndReload(name); + } catch (Exception e) { + log.error("Error checking certificate for {}: {}", name, e.getMessage(), e); + } + }); + } + + private void discoverAndRegisterSslCredentials() { + try { + Map sslConfigBeans = applicationContext.getBeansOfType(SslCredentialsConfig.class); + + log.info("Found {} SslCredentialsConfig beans", sslConfigBeans.size()); + + for (Map.Entry entry : sslConfigBeans.entrySet()) { + String beanName = entry.getKey(); + SslCredentialsConfig config = entry.getValue(); + + try { + if (!config.isEnabled()) { + log.debug("Skipping disabled SSL config: {} ({})", config.getName(), beanName); + continue; + } + + SslCredentials credentials = config.getCredentials(); + if (credentials == null) { + log.debug("Skipping uninitialized SSL config: {} ({})", config.getName(), beanName); + continue; + } + + List filePaths = credentials.getCertificateFilePaths(); + if (filePaths == null || filePaths.isEmpty()) { + log.debug("No file-system certificate paths to watch for: {} ({}) — certificates may be classpath-based", config.getName(), beanName); + continue; + } + + // Register all configured paths, including those that don't exist yet — the watcher uses + // mtime=0 / checksum="" as baseline, so files that appear later (e.g. delayed mounts) are + // picked up and trigger a reload on the next poll. + List pathsToWatch = new ArrayList<>(filePaths.size()); + for (Path filePath : filePaths) { + if (filePath == null) { + continue; + } + pathsToWatch.add(filePath); + if (!Files.exists(filePath)) { + log.warn("Certificate file does not exist yet: {} (from {}) — will be watched and picked up when it appears", + filePath, config.getName()); + } + } + + if (!pathsToWatch.isEmpty()) { + registerWatcher(config.getName(), pathsToWatch, config::onCertificateFileChanged); + log.info("Registered certificate watcher: {} -> {}", config.getName(), pathsToWatch); + } + + } catch (Exception e) { + log.error("Error registering watchers for SSL config: {} ({})", config.getName(), beanName, e); + } + } + + } catch (Exception e) { + log.error("Error discovering SSL credentials configs", e); + } + } + + @Override + public void destroy() throws Exception { + if (scheduler != null) { + scheduler.shutdown(); + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } + } + + @Override + public void afterSingletonsInstantiated() { + if (!reloadEnabled) { + log.trace("Auto-reload of certificates is disabled. Skipping initialization..."); + return; + } + log.info("Initializing Certificate Reload Manager..."); + + discoverAndRegisterSslCredentials(); + + scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("certificate-reload-manager")); + scheduler.scheduleWithFixedDelay(this::checkCertificates, checkIntervalInSeconds, checkIntervalInSeconds, TimeUnit.SECONDS); + } + + static class CertificateWatcher { + private final List paths; + private final Runnable reloadCallback; + private final Map lastModifiedMap; + private final Map lastChecksumMap; + private int consecutiveFailures; + private String failedCombinedChecksum; + + CertificateWatcher(List paths, Runnable reloadCallback) { + this.paths = paths; + this.reloadCallback = reloadCallback; + this.lastModifiedMap = new HashMap<>(); + this.lastChecksumMap = new HashMap<>(); + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + lastChecksumMap.put(path, calculateChecksum(path)); + } + this.consecutiveFailures = 0; + } + + synchronized void checkAndReload(String name) { + boolean anyModifiedChanged = false; + for (Path path : paths) { + long currentModified = getLastModifiedTime(path); + Long lastModified = lastModifiedMap.getOrDefault(path, 0L); + if (currentModified != lastModified) { + anyModifiedChanged = true; + break; + } + } + if (!anyModifiedChanged) { + return; + } + + // Capture mtimes and checksums together before the callback runs. + // Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll. + Map currentModifiedTimes = new HashMap<>(); + Map currentChecksums = new HashMap<>(); + StringBuilder combined = new StringBuilder(); + for (Path path : paths) { + currentModifiedTimes.put(path, getLastModifiedTime(path)); + String checksum = calculateChecksum(path); + currentChecksums.put(path, checksum); + if (!combined.isEmpty()) { + combined.append("|"); + } + combined.append(path).append("=").append(checksum); + } + String combinedChecksum = combined.toString(); + + // Build old combined checksum for comparison + StringBuilder oldCombined = new StringBuilder(); + for (Path path : paths) { + if (!oldCombined.isEmpty()) { + oldCombined.append("|"); + } + oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, "")); + } + String oldCombinedChecksum = oldCombined.toString(); + + if (combinedChecksum.equals(oldCombinedChecksum)) { + // Content unchanged, just update modification times + for (Path path : paths) { + lastModifiedMap.put(path, currentModifiedTimes.get(path)); + } + return; + } + + if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) { + // File content has changed since the last failure - reset and retry + consecutiveFailures = 0; + failedCombinedChecksum = null; + } + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + // Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle + for (Path path : paths) { + lastModifiedMap.put(path, currentModifiedTimes.get(path)); + } + return; + } + + try { + log.info("Certificate change detected for: {}. Triggering reload...", name); + reloadCallback.run(); + for (Path path : paths) { + lastModifiedMap.put(path, currentModifiedTimes.get(path)); + lastChecksumMap.put(path, currentChecksums.get(path)); + } + consecutiveFailures = 0; + failedCombinedChecksum = null; + } catch (Exception e) { + consecutiveFailures++; + failedCombinedChecksum = combinedChecksum; + // Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries + // (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum). + log.error("Failed to reload certificate for {} (attempt {}/{}): {}", + name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); + } + } + + private long getLastModifiedTime(Path path) { + try { + if (!Files.exists(path)) { + return 0; + } + return Files.getLastModifiedTime(path).toMillis(); + } catch (IOException e) { + return 0; + } + } + + private String calculateChecksum(Path path) { + try { + if (!Files.exists(path)) { + return ""; + } + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] buf = new byte[8192]; + try (InputStream is = Files.newInputStream(path)) { + int bytesRead; + while ((bytesRead = is.read(buf)) != -1) { + md.update(buf, 0, bytesRead); + } + } + return Base64.getEncoder().encodeToString(md.digest()); + } catch (Exception e) { + log.warn("Failed to calculate checksum for certificate file: {}", path, e); + return ""; + } + } + + } + +} 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..361453abff 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 @@ -127,9 +127,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -/** - * Created by ashvayka on 17.10.18. - */ @Slf4j @Service @TbTransportComponent @@ -508,7 +505,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); } @@ -789,7 +788,7 @@ public class DefaultTransportService extends TransportActivityManager implements TransportProtos.SessionCloseNotificationProto notification = TransportProtos.SessionCloseNotificationProto.newBuilder().setMessage("session timeout!").build(); - ScheduledFuture executorFuture = scheduler.schedule(() -> { + ScheduledFuture executorFuture = scheduler.schedule(() -> { listener.onRemoteSessionCloseCommand(sessionId, notification); deregisterSession(sessionInfo); }, timeout, TimeUnit.MILLISECONDS); @@ -1169,6 +1168,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onFailure(Throwable t) { DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); } + } private static class StatsCallback implements TbQueueCallback { @@ -1183,16 +1183,19 @@ public class DefaultTransportService extends TransportActivityManager implements @Override public void onSuccess(TbQueueMsgMetadata metadata) { stats.incrementSuccessful(); - if (callback != null) + if (callback != null) { callback.onSuccess(metadata); + } } @Override public void onFailure(Throwable t) { stats.incrementFailed(); - if (callback != null) + if (callback != null) { callback.onFailure(t); + } } + } private class MsgPackCallback implements TbQueueCallback { @@ -1215,6 +1218,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onFailure(Throwable t) { DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); } + } private class ApiStatsProxyCallback implements TransportServiceCallback { @@ -1244,6 +1248,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onError(Throwable e) { callback.onError(e); } + } @Override @@ -1282,4 +1287,5 @@ public class DefaultTransportService extends TransportActivityManager implements log.info("Transport Stats: {}", values); } } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java index 245f167ef8..612871f22a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java @@ -21,9 +21,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import java.util.concurrent.ScheduledFuture; -/** - * Created by ashvayka on 15.10.18. - */ @Data public class SessionMetaData { @@ -47,11 +44,8 @@ public class SessionMetaData { this.scheduledFuture = scheduledFuture; } - public ScheduledFuture getScheduledFuture() { - return scheduledFuture; - } - public boolean hasScheduledFuture() { return null != this.scheduledFuture; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java index a353cf94e4..fd144e0110 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java @@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.queue.kafka.TbKafkaEncoder; -/** - * Created by ashvayka on 05.10.18. - */ public class ToRuleEngineMsgEncoder implements TbKafkaEncoder { @Override public byte[] encode(ToRuleEngineMsg value) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java index 2e1d292a63..13a99686ad 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java @@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder; import java.io.IOException; -/** - * Created by ashvayka on 05.10.18. - */ public class ToTransportMsgResponseDecoder implements TbKafkaDecoder { @Override diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java index 2de10a70fd..4a907c836a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java @@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.queue.kafka.TbKafkaEncoder; -/** - * Created by ashvayka on 05.10.18. - */ public class TransportApiRequestEncoder implements TbKafkaEncoder { @Override public byte[] encode(TransportApiRequestMsg value) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java index cfb7168e66..563d1078c9 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java @@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder; import java.io.IOException; -/** - * Created by ashvayka on 05.10.18. - */ public class TransportApiResponseDecoder implements TbKafkaDecoder { @Override diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index cd0efe2210..535baf921d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import java.util.Optional; import java.util.UUID; -/** - * @author Andrew Shvayka - */ @Data public abstract class DeviceAwareSessionContext implements SessionContext { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java index ee0786aeb1..df28bb3390 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java @@ -31,4 +31,5 @@ public interface SessionContext { void onDeviceProfileUpdate(TransportProtos.SessionInfoProto sessionInfo, DeviceProfile deviceProfile); void onDeviceUpdate(TransportProtos.SessionInfoProto sessionInfo, Device device, Optional deviceProfileOpt); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java index ecdfc479cb..4d5451d4ca 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java @@ -27,8 +27,7 @@ import java.util.regex.Pattern; public class JsonUtils { - private static final Pattern BASE64_PATTERN = - Pattern.compile("^[A-Za-z0-9+/]+={0,2}$"); + private static final Pattern BASE64_PATTERN = Pattern.compile("^[A-Za-z0-9+/]+={0,2}$"); public static JsonObject getJsonObject(List tsKv) { JsonObject json = new JsonObject(); @@ -68,12 +67,12 @@ public class JsonUtils { } return JsonParser.parseString((String) value); } - } else if (value instanceof Boolean) { - return new JsonPrimitive((Boolean) value); - } else if (value instanceof Double) { - return new JsonPrimitive((Double) value); - } else if (value instanceof Float) { - return new JsonPrimitive((Float) value); + } else if (value instanceof Boolean booleanValue) { + return new JsonPrimitive(booleanValue); + } else if (value instanceof Double doubleValue) { + return new JsonPrimitive(doubleValue); + } else if (value instanceof Float floatValue) { + return new JsonPrimitive(floatValue); } else { throw new IllegalArgumentException("Unsupported type: " + value.getClass().getSimpleName()); } @@ -91,4 +90,5 @@ public class JsonUtils { public static boolean isBase64(String value) { return value.length() % 4 == 0 && BASE64_PATTERN.matcher(value).matches(); } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 30598925d5..0158e23d93 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -31,10 +31,6 @@ import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Base64; - -/** - * @author Valerii Sosliuk - */ @Slf4j public class SslUtil { @@ -51,7 +47,7 @@ public class SslUtil { String begin = "-----BEGIN CERTIFICATE-----"; String end = "-----END CERTIFICATE-----"; StringBuilder stringBuilder = new StringBuilder(); - for (Certificate cert: chain) { + for (Certificate cert : chain) { stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64.getEncoder().encodeToString(cert.getEncoded()))).append(end).append("\n"); } return stringBuilder.toString(); @@ -85,4 +81,5 @@ public class SslUtil { RDN cn = x500name.getRDNs(BCStyle.CN)[0]; return IETFUtils.valueToString(cn.getFirst().getValue()); } + } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java new file mode 100644 index 0000000000..ec6d2a0117 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java @@ -0,0 +1,185 @@ +/** + * 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.transport.config.ssl; + +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 java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class SslCredentialsConfigTest { + + @Mock + private SslCredentials mockCredentials; + + private SslCredentialsConfig config; + + @BeforeEach + public void setup() { + config = new SslCredentialsConfig("Test SSL Config", false); + } + + @Test + public void givenConfig_whenCreated_thenShouldHaveCorrectName() { + assertThat(config.getName()).isEqualTo("Test SSL Config"); + assertThat(config.isTrustsOnly()).isFalse(); + } + + @Test + public void givenTrustsOnlyConfig_whenCreated_thenShouldHaveCorrectTrustsOnly() { + SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); + assertThat(trustsOnlyConfig.isTrustsOnly()).isTrue(); + } + + @Test + public void givenCallback_whenRegistered_thenShouldBeStoredInList() { + AtomicInteger callCount = new AtomicInteger(0); + + config.registerReloadCallback(callCount::incrementAndGet); + config.setCredentials(mockCredentials); + + try { + doNothing().when(mockCredentials).reload(false); + } catch (Exception e) { + throw new RuntimeException(e); + } + + config.onCertificateFileChanged(); + + assertThat(callCount.get()).isEqualTo(1); + } + + @Test + public void givenMultipleCallbacks_whenCertificateChanged_thenAllShouldBeCalled() throws Exception { + AtomicInteger callback1Count = new AtomicInteger(0); + AtomicInteger callback2Count = new AtomicInteger(0); + AtomicInteger callback3Count = new AtomicInteger(0); + + config.registerReloadCallback(callback1Count::incrementAndGet); + config.registerReloadCallback(callback2Count::incrementAndGet); + config.registerReloadCallback(callback3Count::incrementAndGet); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callback1Count.get()).isEqualTo(1); + assertThat(callback2Count.get()).isEqualTo(1); + assertThat(callback3Count.get()).isEqualTo(1); + } + + @Test + public void givenCallbackThrowsException_whenCertificateChanged_thenOtherCallbacksShouldStillBeCalled() throws Exception { + AtomicInteger callback1Count = new AtomicInteger(0); + AtomicInteger callback2Count = new AtomicInteger(0); + + config.registerReloadCallback(() -> { + callback1Count.incrementAndGet(); + throw new RuntimeException("Simulated callback failure"); + }); + config.registerReloadCallback(callback2Count::incrementAndGet); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callback1Count.get()).isEqualTo(1); + assertThat(callback2Count.get()).isEqualTo(1); + } + + @Test + public void givenCredentialsReloadFails_whenCertificateChanged_thenShouldRethrowAndNotCallCallbacks() throws Exception { + AtomicInteger callbackCount = new AtomicInteger(0); + + config.registerReloadCallback(callbackCount::incrementAndGet); + config.setCredentials(mockCredentials); + + doThrow(new RuntimeException("Simulated reload failure")).when(mockCredentials).reload(false); + + assertThatThrownBy(() -> config.onCertificateFileChanged()) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to reload SSL credentials"); + + assertThat(callbackCount.get()).isEqualTo(0); + } + + @Test + public void givenCertificateChanged_whenCredentialsReloadSucceeds_thenShouldCallReload() throws Exception { + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + verify(mockCredentials).reload(false); + } + + @Test + public void givenTrustsOnlyConfig_whenCertificateChanged_thenShouldReloadWithTrustsOnlyTrue() throws Exception { + SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); + trustsOnlyConfig.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(true); + + trustsOnlyConfig.onCertificateFileChanged(); + + verify(mockCredentials).reload(true); + } + + @Test + public void givenConcurrentCallbackRegistrations_whenCertificateChanged_thenShouldHandleSafely() throws Exception { + AtomicInteger totalCallbacks = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(10); + + for (int i = 0; i < 10; i++) { + new Thread(() -> { + try { + startLatch.await(); + config.registerReloadCallback(totalCallbacks::incrementAndGet); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + assertThat(completed).isTrue(); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(totalCallbacks.get()).isEqualTo(10); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java new file mode 100644 index 0000000000..b03e1f2edc --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.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.common.transport.config.ssl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class SslCredentialsWebServerCustomizerTest { + + @Mock + private ServerProperties mockServerProperties; + + @Mock + private SslCredentialsConfig mockCredentialsConfig; + + @Mock + private SslCredentials mockCredentials; + + @Mock + private KeyStore mockKeyStore; + + private SslCredentialsWebServerCustomizer customizer; + + @BeforeEach + public void setup() throws Exception { + customizer = new SslCredentialsWebServerCustomizer(mockServerProperties); + ReflectionTestUtils.setField(customizer, "httpServerSslCredentialsConfig", mockCredentialsConfig); + + when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); + when(mockCredentials.getKeyStore()).thenReturn(mockKeyStore); + when(mockCredentials.getKeyPassword()).thenReturn("password"); + when(mockCredentials.getKeyAlias()).thenReturn("server"); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getEncoded()).thenReturn("TEST_CERT_DATA".getBytes()); + when(mockCredentials.getCertificateChain()).thenReturn(new X509Certificate[]{mockCert}); + } + + @Test + public void givenInitialized_whenAfterSingletonsInstantiated_thenShouldRegisterReloadCallback() { + customizer.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenInvoked_thenShouldReloadCertificates() { + customizer.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + reloadCallback.run(); + + verify(mockCredentialsConfig, times(1)).getCredentials(); + } + + @Test + public void givenSslBundles_whenGetBundle_thenShouldReturnValidBundle() { + SslBundles sslBundles = customizer.sslBundles(); + + SslBundle bundle = sslBundles.getBundle("default"); + + assertThat(bundle).isNotNull(); + } + + @Test + public void givenSslBundles_whenGetBundleNames_thenShouldReturnDefault() { + SslBundles sslBundles = customizer.sslBundles(); + + List bundleNames = sslBundles.getBundleNames(); + + assertThat(bundleNames).containsExactly("default"); + } + + @Test + public void givenSslBundles_whenAddUpdateHandler_thenShouldRegisterHandler() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + Consumer handler = bundle -> handlerCallCount.incrementAndGet(); + + sslBundles.addBundleUpdateHandler("default", handler); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + assertThat(handlerCallCount.get()).isEqualTo(1); + } + + @Test + public void givenSslBundles_whenAddUpdateHandlerForWrongBundle_thenShouldNotRegister() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + Consumer handler = bundle -> handlerCallCount.incrementAndGet(); + + sslBundles.addBundleUpdateHandler("wrong-bundle", handler); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + assertThat(handlerCallCount.get()).isEqualTo(0); + } + + @Test + public void givenMultipleUpdateHandlers_whenReload_thenShouldNotifyAll() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handler1CallCount = new AtomicInteger(0); + AtomicInteger handler2CallCount = new AtomicInteger(0); + AtomicInteger handler3CallCount = new AtomicInteger(0); + + sslBundles.addBundleUpdateHandler("default", bundle -> handler1CallCount.incrementAndGet()); + sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); + sslBundles.addBundleUpdateHandler("default", bundle -> handler3CallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + assertThat(handler1CallCount.get()).isEqualTo(1); + assertThat(handler2CallCount.get()).isEqualTo(1); + assertThat(handler3CallCount.get()).isEqualTo(1); + } + + @Test + public void givenMultipleReloads_whenTriggered_thenShouldNotifyHandlersEachTime() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + reloadCallback.run(); + reloadCallback.run(); + reloadCallback.run(); + + assertThat(handlerCallCount.get()).isEqualTo(3); + } + + @Test + public void givenUpdateHandlerThrowsException_whenReload_thenShouldContinueNotifyingOtherHandlers() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handler1CallCount = new AtomicInteger(0); + AtomicInteger handler2CallCount = new AtomicInteger(0); + + sslBundles.addBundleUpdateHandler("default", bundle -> { + handler1CallCount.incrementAndGet(); + throw new RuntimeException("Handler 1 failed"); + }); + sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + assertThat(handler1CallCount.get()).isEqualTo(1); + assertThat(handler2CallCount.get()).isEqualTo(1); + } + + @Test + public void givenConcurrentReloads_whenTriggered_thenShouldHandleThreadSafely() throws Exception { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + reloadCallback.run(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(handlerCallCount.get()).isEqualTo(5); + } + + @Test + public void givenReloadWithFailingCredentials_whenInvoked_thenShouldHandleGracefully() { + when(mockCredentialsConfig.getCredentials()).thenThrow(new RuntimeException("Failed to load credentials")); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + } + + @Test + public void givenSslBundle_whenGetBundleMultipleTimes_thenShouldReturnFreshBundle() { + SslBundles sslBundles = customizer.sslBundles(); + + SslBundle bundle1 = sslBundles.getBundle("default"); + SslBundle bundle2 = sslBundles.getBundle("default"); + + assertThat(bundle1).isNotNull(); + assertThat(bundle2).isNotNull(); + } + + @Test + public void givenHttpServerSslCredentials_whenCreateBean_thenShouldReturnConfig() { + SslCredentialsConfig config = customizer.httpServerSslCredentials(); + + assertThat(config).isNotNull(); + assertThat(config.getName()).isEqualTo("HTTP Server SSL Credentials"); + assertThat(config.isTrustsOnly()).isFalse(); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java new file mode 100644 index 0000000000..6f78eaefae --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -0,0 +1,355 @@ +/** + * 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.transport.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class CertificateReloadManagerTest { + + @TempDir + Path tempDir; + + private CertificateReloadManager certificateReloadManager; + private Path certFile; + + @BeforeEach + public void setup() throws IOException { + certificateReloadManager = new CertificateReloadManager(); + + certFile = tempDir.resolve("test-cert.pem"); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V1\n-----END CERTIFICATE-----\n"); + } + + @AfterEach + public void teardown() throws Exception { + if (certificateReloadManager != null) { + certificateReloadManager.destroy(); + } + } + + private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException { + Files.writeString(path, content); + await().atMost(2, SECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime); + } + + private long mtime(Path path) throws IOException { + return Files.getLastModifiedTime(path).toMillis(); + } + + @Test + public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenOnlyTimestampChanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long bumpedMtime = Files.getLastModifiedTime(certFile).toMillis() + 5_000L; + Files.setLastModifiedTime(certFile, FileTime.fromMillis(bumpedMtime)); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenWatcherRegistered_whenFileDeleted_thenShouldNotCrash() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Files.delete(certFile); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception { + certificateReloadManager.registerWatcher("test-cert", certFile, () -> {}); + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + ReflectionTestUtils.setField(certificateReloadManager, "scheduler", scheduler); + + certificateReloadManager.destroy(); + + assertThat(scheduler.isShutdown()).isTrue(); + assertThat(scheduler.isTerminated()).isTrue(); + } + + @Test + public void givenMultipleCertificateFiles_whenOneChanges_thenShouldTriggerReload() throws Exception { + Path keyFile = tempDir.resolve("test-key.pem"); + Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V1\n-----END PRIVATE KEY-----\n"); + + AtomicInteger certReloadCount = new AtomicInteger(0); + AtomicInteger keyReloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, certReloadCount::incrementAndGet); + certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet); + + long baseline = mtime(keyFile); + writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(keyReloadCount.get()).isEqualTo(1); + assertThat(certReloadCount.get()).isEqualTo(0); + } + + @Test + public void givenMultipleWatchers_whenCheckCertificates_thenShouldCheckAll() throws Exception { + Path cert2File = tempDir.resolve("test-cert2.pem"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); + + AtomicInteger reload1Count = new AtomicInteger(0); + AtomicInteger reload2Count = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); + certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); + + long baseline1 = mtime(certFile); + long baseline2 = mtime(cert2File); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reload1Count.get()).isEqualTo(1); + assertThat(reload2Count.get()).isEqualTo(1); + } + + @Test + public void givenCallbackThrowsException_whenCheckForChanges_thenShouldContinueWithOtherWatchers() throws Exception { + Path cert2File = tempDir.resolve("test-cert2.pem"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); + + AtomicInteger reload2Count = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert1", certFile, () -> { + throw new RuntimeException("Simulated reload failure"); + }); + certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); + + long baseline1 = mtime(certFile); + long baseline2 = mtime(cert2File); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reload2Count.get()).isEqualTo(1); + } + + @Test + public void givenFileDeletedAndRecreated_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Files.delete(certFile); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadCount.get()).isEqualTo(1); + + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadCount.get()).isEqualTo(2); + } + + @Test + public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + for (int i = 0; i < 5; i++) { + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); + } + await().atMost(2, SECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> mtime(certFile) != baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenConcurrentChecks_whenCheckForChanges_thenShouldReloadExactlyOnce() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenSameContentRewritten_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + String originalContent = Files.readString(certFile); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, originalContent, baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenCallbackFailsRepeatedly_whenMaxFailuresReached_thenShouldStopRetrying() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + throw new RuntimeException("Persistent failure"); + }); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + + for (int i = 0; i < 15; i++) { + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } + + assertThat(reloadAttempts.get()).isEqualTo(10); + } + + @Test + public void givenCallbackFailedPreviously_whenFileChangesAgain_thenShouldResetAndRetry() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + AtomicInteger shouldFail = new AtomicInteger(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + if (shouldFail.get() == 1) { + throw new RuntimeException("Transient failure"); + } + }); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(1); + + shouldFail.set(0); + long baseline2 = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(2); + } + + @Test + public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + AtomicInteger shouldFail = new AtomicInteger(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + if (shouldFail.get() == 1) { + throw new RuntimeException("Persistent failure"); + } + }); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + + for (int i = 0; i < 15; i++) { + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } + assertThat(reloadAttempts.get()).isEqualTo(10); + + shouldFail.set(0); + long baseline2 = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(11); + } + +} 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/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index 7367d43910..be49e64d10 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -347,8 +347,10 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe } else if (query.getEntityFilter().getType().equals(ENTITY_NAME)) { validateEntityNameQuery((EntityNameFilter) query.getEntityFilter()); } + // Intentionally using the nullable getKeyFiltersOperation() (not getKeyFiltersOperationOrDefault()): + // a null value encodes "classic AND" and must pass this guard even when the OR feature flag is off. if (!keyFiltersOrConditionsEnabled && query.getKeyFiltersOperation() == ComplexOperation.OR) { - throw new IncorrectParameterException("OR conditions between key filters are disabled"); + throw new IncorrectParameterException("OR conditions between key filters are disabled by the system administrator."); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 639c60e1d6..11bbe797cf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -188,6 +188,12 @@ public class BaseImageService extends BaseResourceService implements ImageServic return findResourceInfoByTenantIdAndKey(tenantId, ResourceType.IMAGE, key); } + @Override + public Set getAllImageKeysByTenantId(TenantId tenantId) { + log.trace("Executing getAllImageKeysByTenantId [{}]", tenantId); + return resourceInfoDao.findKeysByTenantIdAndResourceTypeAndResourceKeyPrefix(tenantId, ResourceType.IMAGE, ""); + } + @Override public TbResourceInfo getPublicImageInfoByKey(String publicResourceKey) { return resourceInfoDao.findPublicResourceByKey(ResourceType.IMAGE, publicResourceKey); 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/AiModelDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java index b955bed043..14e1300856 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java @@ -17,13 +17,19 @@ package org.thingsboard.server.dao.service.validator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.ai.AiModelDao; import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.tenant.TenantService; +import java.net.URI; import java.util.Optional; @Component @@ -64,6 +70,26 @@ class AiModelDataValidator extends DataValidator { if (!tenantService.tenantExists(tenantId)) { throw new DataValidationException("AI model reference a non-existent tenant!"); } + + // provider URL SSRF validation + if (model.getConfiguration() != null) { + AiProviderConfig providerConfig = model.getConfiguration().providerConfig(); + String url = null; + if (providerConfig instanceof OpenAiProviderConfig c) { + url = c.baseUrl(); + } else if (providerConfig instanceof AzureOpenAiProviderConfig c) { + url = c.endpoint(); + } else if (providerConfig instanceof OllamaProviderConfig c) { + url = c.baseUrl(); + } + if (url != null) { + try { + SsrfProtectionValidator.validateUri(URI.create(url)); + } catch (Exception e) { + throw new DataValidationException("AI model provider URL is not allowed: " + e.getMessage()); + } + } + } } } 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/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index 140fe38af4..28a144459d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.sql.query; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -62,6 +63,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -320,10 +322,19 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { .replace("$in", "from").replace("$out", "to") .replace("$rootIdCondition", "in (:relation_root_ids)"); + private static final String NULLS_ORDER_DEFAULT = "default"; + private static final String NULLS_ORDER_FIRST = "nulls_first"; + private static final String NULLS_ORDER_LAST = "nulls_last"; + private static final Set ACCEPTED_NULLS_ORDER_STRATEGIES = Set.of(NULLS_ORDER_DEFAULT, NULLS_ORDER_FIRST, NULLS_ORDER_LAST); + @Getter @Value("${sql.relations.max_level:50}") int maxLevelAllowed; //This value has to be reasonable small to prevent infinite recursion as early as possible + @Getter + @Value("${sql.entity_data_query_nulls_order_strategy:default}") + String nullsOrderStrategy; + private final NamedParameterJdbcTemplate jdbcTemplate; private final TransactionTemplate transactionTemplate; private final DefaultQueryLogComponent queryLog; @@ -334,6 +345,15 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { this.queryLog = queryLog; } + @PostConstruct + void validateNullsOrderStrategy() { + if (!ACCEPTED_NULLS_ORDER_STRATEGIES.contains(nullsOrderStrategy)) { + log.error("Invalid value '{}' for sql.entity_data_query_nulls_order_strategy. Accepted values are: {}. Falling back to '{}'.", + nullsOrderStrategy, ACCEPTED_NULLS_ORDER_STRATEGIES, NULLS_ORDER_DEFAULT); + nullsOrderStrategy = NULLS_ORDER_DEFAULT; + } + } + @Override public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) { EntityType entityType = resolveEntityType(query.getEntityFilter()); @@ -365,10 +385,13 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { List entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) .collect(Collectors.toList()); - // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference + // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference. + // Mirror the ignore=true fix from findEntityDataByQuery so the inner subquery still emits the + // extra column but downstream response shape (benign for count) stays symmetric with the data path. if (isOr) { for (EntityKeyMapping m : entityFieldsFiltersMapping) { if (!selectionMapping.contains(m)) { + m.setIgnore(true); selectionMapping.add(m); } } @@ -464,10 +487,13 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { List entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) .collect(Collectors.toList()); - // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference + // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference. + // Mark force-added filter-only mappings as ignored so EntityDataAdapter does not expose + // them in EntityData.latest — keeps the response shape identical to AND. if (isOr) { for (EntityKeyMapping m : entityFieldsFiltersMapping) { if (!selectionMapping.contains(m)) { + m.setIgnore(true); selectionMapping.add(m); } } @@ -567,11 +593,12 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { if (sortOrderMappingOpt.isPresent()) { EntityKeyMapping sortOrderMapping = sortOrderMappingOpt.get(); String direction = sortOrder.getDirection() == EntityDataSortOrder.Direction.ASC ? "asc" : "desc"; + String nullsOrder = resolveNullsOrder(); if (sortOrderMapping.getEntityKey().getType() == EntityKeyType.ENTITY_FIELD) { - dataQuery = String.format("%s order by %s %s, result.id %s", dataQuery, sortOrderMapping.getValueAlias(), direction, direction); + dataQuery = String.format("%s order by %s %s%s, result.id %s", dataQuery, sortOrderMapping.getValueAlias(), direction, nullsOrder, direction); } else { - dataQuery = String.format("%s order by %s %s, %s %s, result.id %s", dataQuery, - sortOrderMapping.getSortOrderNumAlias(), direction, sortOrderMapping.getSortOrderStrAlias(), direction, direction); + dataQuery = String.format("%s order by %s %s%s, %s %s, result.id %s", dataQuery, + sortOrderMapping.getSortOrderNumAlias(), direction, nullsOrder, sortOrderMapping.getSortOrderStrAlias(), direction, direction); } } } @@ -590,6 +617,14 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { }); } + private String resolveNullsOrder() { + return switch (nullsOrderStrategy) { + case NULLS_ORDER_FIRST -> " NULLS FIRST"; + case NULLS_ORDER_LAST -> " NULLS LAST"; + default -> ""; + }; + } + private String buildEntityWhere(SqlQueryContext ctx, EntityFilter entityFilter, List entityFieldsFilters) { String permissionQuery = this.buildPermissionQuery(ctx, entityFilter); String entityFilterQuery = this.buildEntityFilterQuery(ctx, entityFilter); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index 100c733df6..7fe002e156 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -288,35 +288,28 @@ public class EntityKeyMapping { } public Stream toQueries(SqlQueryContext ctx, EntityFilterType filterType, boolean outerContext) { - if (hasFilter()) { - String keyAlias; - if (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) { - if (outerContext) { - // In the middle layer (OR relocation), entity field columns are available - // by their alias name from the inner subquery SELECT (e.g., "alias2" from - // "cast(e.name as varchar) as alias2"). Temporarily null out entityKeyColumn - // so buildSimplePredicateQuery uses the alias directly as the field. - String savedColumn = this.entityKeyColumn; - try { - this.entityKeyColumn = null; - List predicates = keyFilters.stream() - .map(keyFilter -> this.buildKeyQuery(ctx, alias, keyFilter, filterType)) - .collect(Collectors.toList()); - return predicates.stream(); - } finally { - this.entityKeyColumn = savedColumn; - } - } else { - keyAlias = "e"; - } - } else { + if (!hasFilter()) { + return Stream.empty(); + } + String keyAlias; + boolean useAliasDirectly = false; + if (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) { + if (outerContext) { + // In the middle layer (OR relocation), entity field columns are exposed + // by their alias name from the inner subquery SELECT (e.g., "alias2" from + // "cast(e.name as varchar) as alias2"), so buildSimplePredicateQuery must + // use the alias directly as the field instead of appending entityKeyColumn. keyAlias = alias; + useAliasDirectly = true; + } else { + keyAlias = "e"; } - return keyFilters.stream().map(keyFilter -> - this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType)); } else { - return Stream.empty(); + keyAlias = alias; } + final boolean aliasAsField = useAliasDirectly; + return keyFilters.stream().map(keyFilter -> + this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType, aliasAsField)); } public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType) { @@ -331,7 +324,10 @@ public class EntityKeyMapping { entityTypeStr = "'" + entityType.name() + "'"; } ctx.addStringParameter(getKeyId(), entityKey.getKey()); - String filterQuery = toQueries(ctx, entityFilter.getType()) + // Under OR (forceLeftJoin=true) the filter predicate is re-emitted by buildQuery at the middle + // layer with disjunction semantics. Inlining it in the ON clause here would duplicate the + // predicate with a different bound parameter name and double-filter the LEFT JOIN. + String filterQuery = forceLeftJoin ? "" : toQueries(ctx, entityFilter.getType()) .filter(StringUtils::isNotEmpty) .collect(Collectors.joining(" and ")); if (StringUtils.isNotEmpty(filterQuery)) { @@ -400,6 +396,9 @@ public class EntityKeyMapping { public static String buildQuery(SqlQueryContext ctx, List mappings, EntityFilterType filterType, ComplexOperation operation) { String joiner = (operation == ComplexOperation.OR) ? " OR " : " AND "; + // Vacuously-true predicates (e.g. a ComplexFilterPredicate with zero nested predicates producing + // an empty string) are dropped here. Under AND this is safe — TRUE is the identity for AND — but + // under OR it silently narrows the disjunction. Callers must not emit meaningful empty predicates. return mappings.stream() .flatMap(mapping -> mapping.toQueries(ctx, filterType, operation == ComplexOperation.OR)) .filter(StringUtils::isNotEmpty) @@ -602,8 +601,8 @@ public class EntityKeyMapping { String attrNumAlias = getSortOrderNumAlias(); String attrVarcharAlias = getSortOrderStrAlias(); String attrSortOrderSelection = - String.format("coalesce(%s.dbl_v, cast(%s.long_v as double precision), (case when %s.bool_v then 1 else 0 end)) %s," + - "coalesce(%s.str_v, cast(%s.json_v as varchar), '') %s", alias, alias, alias, attrNumAlias, alias, alias, attrVarcharAlias); + String.format("coalesce(%s.dbl_v, cast(%s.long_v as double precision), (case when %s.bool_v is null then null when %s.bool_v then 1 else 0 end)) %s," + + "coalesce(%s.str_v, cast(%s.json_v as varchar), '') %s", alias, alias, alias, alias, attrNumAlias, alias, alias, attrVarcharAlias); return String.join(", ", attrValSelection, attrTsSelection, attrSortOrderSelection); } else { return String.join(", ", attrValSelection, attrTsSelection); @@ -620,22 +619,27 @@ public class EntityKeyMapping { private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter, EntityFilterType filterType) { - return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType); + return this.buildKeyQuery(ctx, alias, keyFilter, filterType, false); + } + + private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter, + EntityFilterType filterType, boolean useAliasAsField) { + return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType, useAliasAsField); } private String buildPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, - KeyFilterPredicate predicate, EntityFilterType filterType) { + KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) { if (predicate.getType().equals(FilterPredicateType.COMPLEX)) { - return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType); + return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType, useAliasAsField); } else { - return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType); + return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType, useAliasAsField); } } private String buildComplexPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, - ComplexFilterPredicate predicate, EntityFilterType filterType) { + ComplexFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) { String result = predicate.getPredicates().stream() - .map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType)) + .map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType, useAliasAsField)) .filter(StringUtils::isNotEmpty) .collect(Collectors.joining(" " + predicate.getOperation().name() + " ")); if (!result.trim().isEmpty()) { @@ -645,9 +649,9 @@ public class EntityKeyMapping { } private String buildSimplePredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, - KeyFilterPredicate predicate, EntityFilterType filterType) { + KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) { if (key.getType().equals(EntityKeyType.ENTITY_FIELD)) { - String field = (getEntityKeyColumn() != null) ? alias + "." + getEntityKeyColumn() : alias; + String field = useAliasAsField || getEntityKeyColumn() == null ? alias : alias + "." + getEntityKeyColumn(); if (predicate.getType().equals(FilterPredicateType.NUMERIC)) { return this.buildNumericPredicateQuery(ctx, field, (NumericFilterPredicate) predicate); } else if (predicate.getType().equals(FilterPredicateType.STRING)) { 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/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java index 6cfe1c7b36..10c5ae4185 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java @@ -61,8 +61,10 @@ public interface TsKvRepository extends JpaRepository processMinOrMaxResult(AggregationResult aggResult) { if (aggResult.dataType == DataType.DOUBLE || aggResult.dataType == DataType.LONG) { if (aggResult.hasDouble) { - double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(Double.MIN_VALUE); + double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(-Double.MAX_VALUE); double currentL = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.lValue).orElse(Long.MAX_VALUE) : Optional.ofNullable(aggResult.lValue).orElse(Long.MIN_VALUE); return Optional.of(new BasicTsKvEntry(ts, new DoubleDataEntry(key, aggregation == Aggregation.MIN ? Math.min(currentD, currentL) : Math.max(currentD, currentL)))); } else { diff --git a/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java b/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java new file mode 100644 index 0000000000..8afcaa1c22 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java @@ -0,0 +1,47 @@ +/** + * 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.dao; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.TimescaleDBContainerProvider; + +/** + * Extends the upstream {@link TimescaleDBContainerProvider} to disable the + * timescaledb-tune entrypoint script via NO_TS_TUNE=true. + * + * Works around a shell bug in /docker-entrypoint-initdb.d/001_timescaledb_tune.sh + * that crashes the container entrypoint on cgroup v2 hosts (including CI agents) + * when the kernel reports the 64-bit max for memory.max. + * + * Activated by the jdbc:tc:tbtimescaledb:<tag>:///... URL prefix + * registered via META-INF/services. + */ +public class TbTimescaleDBContainerProvider extends TimescaleDBContainerProvider { + + private static final String NAME = "tbtimescaledb"; + + @Override + public boolean supports(String databaseType) { + return NAME.equals(databaseType); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + JdbcDatabaseContainer container = super.newInstance(tag); + container.withEnv("NO_TS_TUNE", "true"); + return container; + } +} 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/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 221684c10c..e7d2d6a8fc 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -710,6 +710,31 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue()); } + @Test + public void testFindDeviceMaxAggregationOverNegativeMixedLongAndDoubleTsData() throws Exception { + save(deviceId, 5000, -100L); + save(deviceId, 15000, -50.0); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(1, list.size()); + assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue()); + } + + @Test + public void testFindDeviceMaxAggregationOverAllNegativeDoubleTsData() throws Exception { + save(deviceId, 5000, -50.0); + save(deviceId, 15000, -100.0); + save(deviceId, 25000, -75.0); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(1, list.size()); + assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue()); + } + @Test public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception { save(deviceId, 2000000L, 95); diff --git a/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 0000000000..ab36744aa9 --- /dev/null +++ b/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.thingsboard.server.dao.TbTimescaleDBContainerProvider diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties index b688c3c40f..921aebd5fe 100644 --- a/dao/src/test/resources/nosql-test.properties +++ b/dao/src/test/resources/nosql-test.properties @@ -13,6 +13,6 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties index e3f4861aa9..0639c461a3 100644 --- a/dao/src/test/resources/sql-test.properties +++ b/dao/src/test/resources/sql-test.properties @@ -14,7 +14,7 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/timescale-test.properties b/dao/src/test/resources/timescale-test.properties index 2c5552cb75..e0c0bef25e 100644 --- a/dao/src/test/resources/timescale-test.properties +++ b/dao/src/test/resources/timescale-test.properties @@ -13,6 +13,6 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:timescaledb:latest-pg12:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb +spring.datasource.url=jdbc:tc:tbtimescaledb:latest-pg18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize = 50 diff --git a/edqs/src/main/resources/edqs.yml b/edqs/src/main/resources/edqs.yml index 93914fffbe..cad0fcaccc 100644 --- a/edqs/src/main/resources/edqs.yml +++ b/edqs/src/main/resources/edqs.yml @@ -103,8 +103,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java index 5d4905f0ef..11423d7484 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java @@ -19,15 +19,20 @@ import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.AttributeKv; import org.thingsboard.server.common.data.edqs.LatestTsKv; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -39,8 +44,10 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.StringFilterPredicate; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.UUID; public class DeviceTypeFilterTest extends AbstractEDQTest { @@ -119,7 +126,50 @@ public class DeviceTypeFilterTest extends AbstractEDQTest { Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); } + @Test + public void testFindDeviceByBooleanAttributeWithMixedTypes() { + DeviceId device1Id = createLoraDevice("LoRa-1"); + DeviceId device2Id = createLoraDevice("LoRa-2"); + DeviceId device3Id = createLoraDevice("LoRa-3"); + + long ts = System.currentTimeMillis(); + addOrUpdate(new AttributeKv(device1Id, AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new BooleanDataEntry("active", true), ts), 1L)); + addOrUpdate(new AttributeKv(device2Id, AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new BooleanDataEntry("active", false), ts), 1L)); + addOrUpdate(new AttributeKv(device3Id, AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new StringDataEntry("active", "true"), ts), 1L)); + + KeyFilter activeFilter = new KeyFilter(); + activeFilter.setKey(new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, "active")); + activeFilter.setValueType(EntityKeyValueType.BOOLEAN); + BooleanFilterPredicate predicate = new BooleanFilterPredicate(); + predicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); + predicate.setValue(FilterPredicateValue.fromBoolean(true)); + activeFilter.setPredicate(predicate); + + var result = repository.countEntitiesByQuery(tenantId, null, + getDeviceTypeQuery("LoRa", List.of(activeFilter)), false); + Assert.assertEquals(2, result); + } + + private DeviceId createLoraDevice(String name) { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setDeviceProfileId(loraProfileId); + device.setName(name); + device.setCreatedTime(42L); + addOrUpdate(EntityType.DEVICE, device); + return deviceId; + } + private static EntityDataQuery getDeviceTypeQuery(String deviceType) { + return getDeviceTypeQuery(deviceType, null); + } + + private static EntityDataQuery getDeviceTypeQuery(String deviceType, List extraFilters) { DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(Collections.singletonList(deviceType)); var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); @@ -135,7 +185,12 @@ public class DeviceTypeFilterTest extends AbstractEDQTest { nameFilter.setPredicate(predicate); nameFilter.setValueType(EntityKeyValueType.STRING); - return new EntityDataQuery(filter, pageLink, entityFields, latestValues, Arrays.asList(nameFilter)); + List keyFilters = new ArrayList<>(); + keyFilters.add(nameFilter); + if (extraFilters != null) { + keyFilters.addAll(extraFilters); + } + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); } } diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 9b66b64009..c3dfaf00c7 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -79,6 +79,10 @@ org.apache.httpcomponents httpclient + + com.slack.api + slack-api-client + org.eclipse.leshan leshan-client-cf @@ -118,6 +122,11 @@ ch.qos.logback logback-classic + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java index 251b5e1d85..cc9801dcad 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java @@ -16,13 +16,21 @@ package org.thingsboard.monitoring.config.transport; import lombok.Data; +import org.thingsboard.monitoring.data.notification.ShortNameProvider; @Data -public class TransportInfo { +public class TransportInfo implements ShortNameProvider { private final TransportType type; private final TransportMonitoringTarget target; + public String getShortName() { + if (target.getQueue().equals("Main")) { + return type.getName(); + } + return type.getName() + " " + target.getQueue(); + } + @Override public String toString() { if (target.getQueue().equals("Main")) { diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java index 28fa6a9e18..c566e8f896 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java @@ -18,6 +18,9 @@ package org.thingsboard.monitoring.data; public class MonitoredServiceKey { public static final String GENERAL = "Monitoring"; + public static final String LOGIN = "Login"; + public static final String WS_CONNECT = "WS Connect"; + public static final String WS_SUBSCRIBE = "WS Subscribe"; public static final String EDQS = "*EDQS*"; } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java new file mode 100644 index 0000000000..b30a398223 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java @@ -0,0 +1,34 @@ +/** + * 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.monitoring.data.notification; + +public record AffectedService(String name, Status status, int failureCount) { + + public enum Status { FAILING, RECOVERED, HIGH_LATENCY } + + public static AffectedService failing(String name, int failureCount) { + return new AffectedService(name, Status.FAILING, failureCount); + } + + public static AffectedService recovered(String name) { + return new AffectedService(name, Status.RECOVERED, 0); + } + + public static AffectedService highLatency(String name) { + return new AffectedService(name, Status.HIGH_LATENCY, 0); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java index 9a319eb6c9..3e739ec4dc 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java @@ -18,6 +18,8 @@ package org.thingsboard.monitoring.data.notification; import org.thingsboard.monitoring.data.Latency; import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; public class HighLatencyNotification implements Notification { @@ -39,4 +41,11 @@ public class HighLatencyNotification implements Notification { return text.toString(); } + @Override + public List getAffectedServices() { + return highLatencies.stream() + .map(latency -> AffectedService.highLatency(latency.getKey())) + .collect(Collectors.toList()); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java index b2b73414de..6906e8c732 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java @@ -24,4 +24,9 @@ public class InfoNotification implements Notification { public String getText() { return message; } + + @Override + public boolean isIncident() { + return false; + } } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java index 33ed7f8328..7734fc62ee 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java @@ -15,8 +15,18 @@ */ package org.thingsboard.monitoring.data.notification; +import java.util.List; + public interface Notification { String getText(); + default boolean isIncident() { + return true; + } + + default List getAffectedServices() { + return List.of(); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java index dd93f232a6..69e336f334 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java @@ -18,6 +18,10 @@ package org.thingsboard.monitoring.data.notification; import lombok.Getter; import org.apache.commons.lang3.exception.ExceptionUtils; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + @Getter public class ServiceFailureNotification implements Notification { @@ -43,7 +47,65 @@ public class ServiceFailureNotification implements Notification { if (errorMsg == null) { errorMsg = error.getClass().getSimpleName(); } + errorMsg = stripResponseBody(errorMsg); + errorMsg = linkifyRequestUrl(errorMsg); return String.format("%s - Failure: %s (number of subsequent failures: %s)", serviceKey, errorMsg, failuresCount); } + // Spring RestClient: '... request for ""' + private static final Pattern REQUEST_FOR_URL_PATTERN = Pattern.compile("request for \"(https?://[^\"\\s]+)\""); + // Apache HttpClient wrapped by Spring: 'I/O error on POST request: Connect to failed: ' + private static final Pattern REQUEST_CONNECT_PATTERN = Pattern.compile("request: Connect to (https?://\\S+?) failed:"); + + static String linkifyRequestUrl(String msg) { + if (msg == null) { + return null; + } + // Slack mrkdwn link: + Matcher m = REQUEST_FOR_URL_PATTERN.matcher(msg); + if (m.find()) { + return m.replaceAll("<$1|request>"); + } + Matcher m2 = REQUEST_CONNECT_PATTERN.matcher(msg); + if (m2.find()) { + return m2.replaceAll("<$1|request>:"); + } + return msg; + } + + static String stripResponseBody(String msg) { + if (msg == null) { + return null; + } + int htmlIdx = -1; + for (String marker : new String[]{"= 0 && (htmlIdx < 0 || idx < htmlIdx)) { + htmlIdx = idx; + } + } + if (htmlIdx > 0) { + msg = msg.substring(0, htmlIdx).stripTrailing(); + if (msg.endsWith("\"")) { + msg = msg.substring(0, msg.length() - 1).stripTrailing(); + } + if (msg.endsWith(":")) { + msg = msg.substring(0, msg.length() - 1).stripTrailing(); + } + } + return msg; + } + + @Override + public List getAffectedServices() { + return List.of(AffectedService.failing(shortName(serviceKey), failuresCount)); + } + + static String shortName(Object serviceKey) { + if (serviceKey instanceof ShortNameProvider provider) { + return provider.getShortName(); + } + return serviceKey.toString(); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java index b6e0c7c695..31b32422a1 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java @@ -15,6 +15,8 @@ */ package org.thingsboard.monitoring.data.notification; +import java.util.List; + public class ServiceRecoveryNotification implements Notification { private final Object serviceKey; @@ -28,4 +30,9 @@ public class ServiceRecoveryNotification implements Notification { return String.format("%s is OK", serviceKey); } + @Override + public List getAffectedServices() { + return List.of(AffectedService.recovered(ServiceFailureNotification.shortName(serviceKey))); + } + } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java similarity index 83% rename from ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss rename to monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java index 2fd238043d..96c63023af 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java @@ -13,5 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host { +package org.thingsboard.monitoring.data.notification; + +public interface ShortNameProvider { + + String getShortName(); + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java index e813b3205d..091a331438 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java @@ -51,7 +51,7 @@ public class NotificationService { return notificationChannels.stream().map(notificationChannel -> notificationExecutor.submit(() -> { try { - notificationChannel.sendNotification(message); + notificationChannel.sendNotification(message, notification); } catch (Exception e) { log.error("Failed to send notification to {}", notificationChannel.getClass().getSimpleName(), e); } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java index 126942cb3f..614fddda31 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java @@ -15,8 +15,10 @@ */ package org.thingsboard.monitoring.notification.channels; +import org.thingsboard.monitoring.data.notification.Notification; + public interface NotificationChannel { - void sendNotification(String message); + void sendNotification(String message, Notification notification); } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java new file mode 100644 index 0000000000..4ad74d7811 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java @@ -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. + */ +package org.thingsboard.monitoring.notification.channels.impl; + +import com.slack.api.Slack; +import com.slack.api.SlackConfig; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiTextResponse; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.request.chat.ChatUpdateRequest; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.methods.response.chat.ChatUpdateResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SlackApiClient { + + private static final int DEFAULT_CALL_TIMEOUT_MS = 5000; + + private final Slack slack; + private final String botToken; + + public SlackApiClient(String botToken) { + this(botToken, DEFAULT_CALL_TIMEOUT_MS); + } + + public SlackApiClient(String botToken, int callTimeoutMs) { + this.botToken = botToken; + SlackConfig config = new SlackConfig(); + config.setHttpClientCallTimeoutMillis(callTimeoutMs); + config.setHttpClientReadTimeoutMillis(callTimeoutMs); + config.setHttpClientWriteTimeoutMillis(callTimeoutMs); + this.slack = Slack.getInstance(config); + } + + public String postMessage(String channelId, String text) { + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(channelId) + .text(text) + .build(); + ChatPostMessageResponse response = sendRequest(request); + return response.getTs(); + } + + public String postThreadReply(String channelId, String threadTs, String text) { + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(channelId) + .text(text) + .threadTs(threadTs) + .build(); + ChatPostMessageResponse response = sendRequest(request); + return response.getTs(); + } + + public void close() { + try { + slack.close(); + } catch (Exception e) { + log.warn("Failed to close Slack client", e); + } + } + + public void updateMessage(String channelId, String ts, String text) { + ChatUpdateRequest request = ChatUpdateRequest.builder() + .channel(channelId) + .ts(ts) + .text(text) + .build(); + MethodsClient client = slack.methods(botToken); + ChatUpdateResponse response; + try { + response = client.chatUpdate(request); + } catch (Exception e) { + throw new RuntimeException("Failed to update Slack message: " + e.getMessage(), e); + } + checkResponse(response); + } + + private ChatPostMessageResponse sendRequest(ChatPostMessageRequest request) { + MethodsClient client = slack.methods(botToken); + ChatPostMessageResponse response; + try { + response = client.chatPostMessage(request); + } catch (Exception e) { + throw new RuntimeException("Failed to send Slack message: " + e.getMessage(), e); + } + checkResponse(response); + return response; + } + + private void checkResponse(SlackApiTextResponse response) { + if (response.isOk()) { + return; + } + String error = response.getError(); + if (error != null) { + switch (error) { + case "missing_scope" -> { + String neededScope = response.getNeeded(); + error = "bot token scope '" + neededScope + "' is needed"; + } + case "not_in_channel" -> error = "app needs to be added to the channel"; + } + } else if (response.getWarning() != null) { + error = "warning: " + response.getWarning(); + } else { + error = "unknown error"; + } + throw new RuntimeException("Slack API error: " + error); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java new file mode 100644 index 0000000000..8812714d62 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java @@ -0,0 +1,45 @@ +/** + * 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.monitoring.notification.channels.impl; + +import org.thingsboard.monitoring.notification.incident.IncidentTransport; + +public class SlackIncidentTransport implements IncidentTransport { + + private final SlackApiClient slackApiClient; + private final String channelId; + + public SlackIncidentTransport(SlackApiClient slackApiClient, String channelId) { + this.slackApiClient = slackApiClient; + this.channelId = channelId; + } + + @Override + public String postIncident(String text) { + return slackApiClient.postMessage(channelId, text); + } + + @Override + public void postThreadReply(String threadId, String text) { + slackApiClient.postThreadReply(channelId, threadId, text); + } + + @Override + public void updateIncident(String threadId, String text) { + slackApiClient.updateMessage(channelId, threadId, text); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java index c1d25c43c0..06990c5a51 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java @@ -16,13 +16,16 @@ package org.thingsboard.monitoring.notification.channels.impl; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import org.thingsboard.monitoring.data.notification.Notification; import org.thingsboard.monitoring.notification.channels.NotificationChannel; +import org.thingsboard.monitoring.notification.incident.IncidentManager; import java.time.Duration; import java.util.Map; @@ -35,19 +38,73 @@ public class SlackNotificationChannel implements NotificationChannel { @Value("${monitoring.notifications.slack.webhook_url}") private String webhookUrl; + @Value("${monitoring.notifications.slack.bot_token:}") + private String botToken; + + @Value("${monitoring.notifications.slack.channel_id:}") + private String channelId; + + @Value("${monitoring.notifications.incident.enabled:}") + private boolean incidentEnabled; + + @Value("${monitoring.notifications.incident.resolution_timeout_s:}") + private long resolutionTimeoutSeconds; + + @Value("${monitoring.notifications.incident.tag_channel:}") + private boolean tagChannel; + + @Value("${monitoring.notifications.message_prefix:}") + private String messagePrefix; + private RestTemplate restTemplate; + private SlackApiClient slackApiClient; + private IncidentManager incidentManager; @PostConstruct private void init() { - restTemplate = new RestTemplateBuilder() - .setConnectTimeout(Duration.ofSeconds(5)) - .setReadTimeout(Duration.ofSeconds(2)) - .build(); + boolean hasBotConfig = botToken != null && !botToken.isEmpty() && channelId != null && !channelId.isEmpty(); + if (hasBotConfig) { + slackApiClient = new SlackApiClient(botToken); + log.info("Slack API mode enabled (channel: {})", channelId); + if (incidentEnabled) { + incidentManager = new IncidentManager(new SlackIncidentTransport(slackApiClient, channelId), + resolutionTimeoutSeconds, messagePrefix, tagChannel); + log.info("Incident grouping enabled via Slack (resolution timeout: {}s)", resolutionTimeoutSeconds); + } + } else { + if (incidentEnabled) { + log.warn("Incident grouping is enabled but Slack bot_token/channel_id are not set; " + + "falling back to plain webhook mode without incident support"); + } + restTemplate = new RestTemplateBuilder() + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(2)) + .build(); + log.info("Slack webhook mode enabled"); + } } @Override - public void sendNotification(String message) { - restTemplate.postForObject(webhookUrl, Map.of("text", message), String.class); + public void sendNotification(String message, Notification notification) { + if (incidentManager != null && notification.isIncident()) { + // Pass the raw notification text: IncidentManager already puts the prefix into the + // incident header, so pre-prefixing the thread reply would double it up. + incidentManager.sendAlert(notification.getText(), notification.getAffectedServices()); + } else if (slackApiClient != null) { + slackApiClient.postMessage(channelId, message); + } else { + restTemplate.postForObject(webhookUrl, Map.of("text", message), String.class); + } + } + + @PreDestroy + private void destroy() { + if (incidentManager != null) { + incidentManager.shutdown(); + } + if (slackApiClient != null) { + slackApiClient.close(); + } } } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java new file mode 100644 index 0000000000..c25abfc081 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java @@ -0,0 +1,292 @@ +/** + * 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.monitoring.notification.incident; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.monitoring.data.notification.AffectedService; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Thread-safety: all public entry points and scheduled callbacks are {@code synchronized} on the + * manager instance. Transport I/O is performed while the monitor is held. This is safe under the + * assumptions that (a) the transport enforces a short per-call timeout, and (b) notification + * producers are single-threaded; see the Slack client (default 5s) for the Slack-based transport. + */ +@Slf4j +public class IncidentManager { + + private final IncidentTransport transport; + private final long resolutionTimeoutSeconds; + private final String messagePrefix; + private final boolean tagChannel; + private final ScheduledExecutorService scheduler; + + private String activeIncidentThreadId; + private ScheduledFuture resolutionTask; + private ScheduledFuture durationUpdateTask; + private Instant incidentStartTime; + private Instant lastAlertTime; + private final Map failingServices = new LinkedHashMap<>(); + private final Map recoveredServices = new LinkedHashMap<>(); + private final Set highLatencyServices = new LinkedHashSet<>(); + + public IncidentManager(IncidentTransport transport, long resolutionTimeoutSeconds, + String messagePrefix, boolean tagChannel) { + this.transport = transport; + this.resolutionTimeoutSeconds = resolutionTimeoutSeconds; + this.messagePrefix = messagePrefix; + this.tagChannel = tagChannel; + this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "incident-manager"); + t.setDaemon(true); + return t; + }); + } + + public synchronized void sendAlert(String message, List affectedServices) { + try { + if (activeIncidentThreadId == null) { + if (affectedServices.stream().allMatch(s -> s.status() == AffectedService.Status.RECOVERED)) { + return; + } + incidentStartTime = Instant.now(); + failingServices.clear(); + recoveredServices.clear(); + highLatencyServices.clear(); + applyAffectedServices(affectedServices); + activeIncidentThreadId = transport.postIncident(buildOngoingMessageText()); + startDurationUpdater(); + log.info("New incident created, thread id: {}", activeIncidentThreadId); + } else if (applyAffectedServices(affectedServices)) { + safeUpdateHeader(); + } + + try { + transport.postThreadReply(activeIncidentThreadId, message); + log.debug("Alert added to incident thread {}", activeIncidentThreadId); + } catch (Exception e) { + log.error("Failed to post alert to incident thread {}", activeIncidentThreadId, e); + } + } finally { + if (activeIncidentThreadId != null) { + lastAlertTime = Instant.now(); + // High latency is a warning only — it has no explicit recovery signal + // (HighLatencyNotification fires only when something is above threshold), + // so resolution hinges on failing services alone. + if (failingServices.isEmpty()) { + resetResolutionTimer(); + } else { + cancelResolutionTimer(); + } + } + } + } + + private boolean applyAffectedServices(List affectedServices) { + boolean changed = false; + Set latencySnapshot = null; + for (AffectedService service : affectedServices) { + String name = service.name(); + switch (service.status()) { + case FAILING -> { + Integer prev = failingServices.put(name, service.failureCount()); + if (prev == null || prev.intValue() != service.failureCount()) { + changed = true; + } + if (recoveredServices.remove(name) != null) { + changed = true; + } + } + case RECOVERED -> { + Integer lastFailureCount = failingServices.remove(name); + if (lastFailureCount != null) { + recoveredServices.put(name, lastFailureCount); + changed = true; + } + } + case HIGH_LATENCY -> { + if (latencySnapshot == null) { + latencySnapshot = new LinkedHashSet<>(); + } + latencySnapshot.add(name); + } + } + } + // HighLatencyNotification carries the full current set of high latencies, so treat it as a + // snapshot: replace highLatencyServices entirely. Without this, a brief spike would stay + // yellow in the header until the incident resolves. + if (latencySnapshot != null && !latencySnapshot.equals(highLatencyServices)) { + highLatencyServices.clear(); + highLatencyServices.addAll(latencySnapshot); + changed = true; + } + return changed; + } + + private String buildOngoingMessageText() { + StringBuilder sb = new StringBuilder(); + if (tagChannel) { + sb.append(" "); + } + if (messagePrefix != null && !messagePrefix.isEmpty()) { + sb.append("*").append(messagePrefix).append("*"); + } + sb.append(" :rotating_light:"); + Duration elapsed = Duration.between(incidentStartTime, Instant.now()); + if (elapsed.toMinutes() >= 1) { + sb.append(" (").append(formatDuration(elapsed)).append(")"); + } + if (hasAffected()) { + sb.append(" | ").append(formatAffectedServices()); + } + return sb.toString(); + } + + private boolean hasAffected() { + return !failingServices.isEmpty() || !recoveredServices.isEmpty() || !highLatencyServices.isEmpty(); + } + + private void safeUpdateHeader() { + try { + transport.updateIncident(activeIncidentThreadId, buildOngoingMessageText()); + } catch (Exception e) { + log.error("Failed to update incident message", e); + } + } + + private void resetResolutionTimer() { + cancelResolutionTimer(); + resolutionTask = scheduler.schedule(this::resolveIncident, resolutionTimeoutSeconds, TimeUnit.SECONDS); + } + + private void cancelResolutionTimer() { + if (resolutionTask != null) { + resolutionTask.cancel(false); + resolutionTask = null; + } + } + + private void startDurationUpdater() { + if (durationUpdateTask != null) { + durationUpdateTask.cancel(false); + } + durationUpdateTask = scheduler.scheduleAtFixedRate(this::updateDuration, 60, 60, TimeUnit.SECONDS); + } + + private synchronized void updateDuration() { + if (activeIncidentThreadId == null) { + return; + } + safeUpdateHeader(); + } + + private void stopDurationUpdater() { + if (durationUpdateTask != null) { + durationUpdateTask.cancel(false); + durationUpdateTask = null; + } + } + + static String formatDuration(Duration duration) { + long totalMinutes = duration.toMinutes(); + if (totalMinutes < 60) { + return totalMinutes + "m"; + } + long hours = totalMinutes / 60; + long minutes = totalMinutes % 60; + return minutes > 0 ? hours + "h" + minutes + "m" : hours + "h"; + } + + synchronized void resolveIncident() { + if (activeIncidentThreadId == null) { + return; + } + String threadId = activeIncidentThreadId; + stopDurationUpdater(); + String resolutionMessage = buildResolutionMessage(); + activeIncidentThreadId = null; + resolutionTask = null; + failingServices.clear(); + recoveredServices.clear(); + highLatencyServices.clear(); + try { + transport.updateIncident(threadId, resolutionMessage); + log.info("Incident resolved (thread was {})", threadId); + } catch (Exception e) { + log.error("Failed to send incident resolution message", e); + } + } + + private String buildResolutionMessage() { + Duration totalDuration = lastAlertTime != null + ? Duration.between(incidentStartTime, lastAlertTime) + : Duration.between(incidentStartTime, Instant.now()); + StringBuilder sb = new StringBuilder(); + if (messagePrefix != null && !messagePrefix.isEmpty()) { + sb.append("*").append(messagePrefix).append("*"); + } + sb.append(" :white_check_mark:"); + sb.append(" (").append(formatDuration(totalDuration)).append(")"); + if (hasAffected()) { + sb.append(" | ").append(formatAffectedServices()).append("\n"); + } + return sb.toString(); + } + + private String formatAffectedServices() { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Map.Entry entry : failingServices.entrySet()) { + if (!first) sb.append(", "); + sb.append(":red_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")"); + first = false; + } + for (String name : highLatencyServices) { + if (!first) sb.append(", "); + sb.append(":large_yellow_circle: ").append(name); + first = false; + } + for (Map.Entry entry : recoveredServices.entrySet()) { + if (!first) sb.append(", "); + sb.append(":large_green_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")"); + first = false; + } + return sb.toString(); + } + + public void shutdown() { + scheduler.shutdownNow(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + log.warn("Incident scheduler did not terminate in time"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java new file mode 100644 index 0000000000..0d6b4cac2a --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java @@ -0,0 +1,26 @@ +/** + * 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.monitoring.notification.incident; + +public interface IncidentTransport { + + String postIncident(String text); + + void postThreadReply(String threadId, String text); + + void updateIncident(String threadId, String text); + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java index b73194469f..c0e85da1ac 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java @@ -89,11 +89,10 @@ public abstract class BaseHealthChecker { diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java index ea154e49c0..9c8c8fc533 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java @@ -117,28 +117,60 @@ public abstract class BaseMonitoringService, T ext } try { log.info("Starting {}", getName()); - stopWatch.start(); - String accessToken = tbClient.logIn(); - reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime()); - try (WsClient wsClient = wsClientFactory.createClient(accessToken)) { + String accessToken; + try { stopWatch.start(); - wsClient.subscribeForTelemetry(devices, getTestTelemetryKeys()).waitForReply(); - reporter.reportLatency(Latencies.WS_SUBSCRIBE, stopWatch.getTime()); + accessToken = tbClient.logIn(); + reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime()); + reporter.serviceIsOk(MonitoredServiceKey.LOGIN); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.LOGIN, e); + return; + } + + WsClient wsClient; + try { + wsClient = wsClientFactory.createClient(accessToken); + reporter.serviceIsOk(MonitoredServiceKey.WS_CONNECT); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.WS_CONNECT, e); + return; + } + + try (WsClient ws = wsClient) { + try { + stopWatch.start(); + ws.subscribeForTelemetry(devices, getTestTelemetryKeys()).waitForReply(); + reporter.reportLatency(Latencies.WS_SUBSCRIBE, stopWatch.getTime()); + reporter.serviceIsOk(MonitoredServiceKey.WS_SUBSCRIBE); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.WS_SUBSCRIBE, e); + return; + } for (BaseHealthChecker healthChecker : healthCheckers) { - check(healthChecker, wsClient); + check(healthChecker, ws); } } if (checkEdqs) { - stopWatch.start(); - checkEdqs(); - reporter.reportLatency(Latencies.EDQS_QUERY, stopWatch.getTime()); - reporter.serviceIsOk(MonitoredServiceKey.EDQS); + try { + stopWatch.start(); + checkEdqs(); + reporter.reportLatency(Latencies.EDQS_QUERY, stopWatch.getTime()); + reporter.serviceIsOk(MonitoredServiceKey.EDQS); + } catch (ServiceFailureException e) { + reporter.serviceFailure(e.getServiceKey(), e); + return; + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.EDQS, e); + return; + } } reporter.reportLatencies(); + reporter.serviceIsOk(MonitoredServiceKey.GENERAL); log.debug("Finished {}", getName()); } catch (ServiceFailureException e) { reporter.serviceFailure(e.getServiceKey(), e); diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java index b13394dbdc..75a8a819c0 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java @@ -25,7 +25,6 @@ import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.monitoring.client.TbClient; import org.thingsboard.monitoring.data.Latency; -import org.thingsboard.monitoring.data.MonitoredServiceKey; import org.thingsboard.monitoring.data.notification.HighLatencyNotification; import org.thingsboard.monitoring.data.notification.ServiceFailureNotification; import org.thingsboard.monitoring.data.notification.ServiceRecoveryNotification; @@ -119,9 +118,7 @@ public class MonitoringReporter { public void serviceIsOk(Object serviceKey) { ServiceRecoveryNotification notification = new ServiceRecoveryNotification(serviceKey); - if (!serviceKey.equals(MonitoredServiceKey.GENERAL)) { - log.info(notification.getText()); - } + log.info(notification.getText()); AtomicInteger failuresCounter = failuresCounters.get(serviceKey); if (failuresCounter != null) { if (failuresCounter.get() >= failuresThreshold) { diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml index 053e42bb9d..768fd2e4c7 100644 --- a/monitoring/src/main/resources/tb-monitoring.yml +++ b/monitoring/src/main/resources/tb-monitoring.yml @@ -121,11 +121,24 @@ monitoring: notifications: message_prefix: '${NOTIFICATION_MESSAGE_PREFIX:}' + # Incident grouping (threads alerts into incidents, auto-resolves after timeout). + # Requires a channel that supports it — currently only Slack API (bot_token + channel_id). + incident: + # Enable incident grouping + enabled: '${INCIDENT_ENABLED:false}' + # Incident resolution timeout in seconds + resolution_timeout_s: '${INCIDENT_RESOLUTION_TIMEOUT_S:90}' + # Tag @channel in incident messages + tag_channel: '${INCIDENT_TAG_CHANNEL:false}' slack: # Enable notifying via Slack enabled: '${SLACK_NOTIFICATION_CHANNEL_ENABLED:false}' # Slack webhook url webhook_url: '${SLACK_WEBHOOK_URL:}' + # Slack Bot OAuth token (xoxb-...) for API-based messaging with incident support, requires chat:write:bot scope + bot_token: '${SLACK_BOT_TOKEN:}' + # Slack channel ID (e.g. C01234ABCDE) - required when incident feature is enabled + channel_id: '${SLACK_CHANNEL_ID:}' latency: # Enable latencies reporting diff --git a/monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java b/monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java new file mode 100644 index 0000000000..500a713f7b --- /dev/null +++ b/monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java @@ -0,0 +1,97 @@ +/** + * 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.monitoring.data.notification; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ServiceFailureNotificationTest { + + @Test + void stripResponseBodyRemovesNginxErrorHtml() { + String msg = "503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\": \"" + + "503 Service Temporarily Unavailable" + + "

503 Service Temporarily Unavailable


nginx
\""; + + String sanitized = ServiceFailureNotification.stripResponseBody(msg); + + assertThat(sanitized) + .isEqualTo("503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\""); + } + + @Test + void stripResponseBodyRemovesDoctypeHtml() { + String msg = "500 Internal Server Error: \"...\""; + + String sanitized = ServiceFailureNotification.stripResponseBody(msg); + + assertThat(sanitized).isEqualTo("500 Internal Server Error"); + } + + @Test + void stripResponseBodyLeavesPlainMessagesUntouched() { + String msg = "Connection refused"; + assertThat(ServiceFailureNotification.stripResponseBody(msg)).isEqualTo(msg); + } + + @Test + void stripResponseBodyHandlesNull() { + assertThat(ServiceFailureNotification.stripResponseBody(null)).isNull(); + } + + @Test + void linkifyReplacesRequestForUrlWithSlackMrkdwnLink() { + String msg = "503 Service Temporarily Unavailable on POST request for \"https://example.com/api/auth/login\""; + + assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)) + .isEqualTo("503 Service Temporarily Unavailable on POST "); + } + + @Test + void linkifyReplacesRequestConnectToUrlFailed() { + String msg = "I/O error on POST request: Connect to https://example.com:443 failed: Connect timed out"; + + assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)) + .isEqualTo("I/O error on POST : Connect timed out"); + } + + @Test + void linkifyLeavesMessagesWithoutRequestUrlUntouched() { + String msg = "Connection refused"; + assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)).isEqualTo(msg); + } + + @Test + void linkifyHandlesNull() { + assertThat(ServiceFailureNotification.linkifyRequestUrl(null)).isNull(); + } + + @Test + void shortNameUsesShortNameProviderWhenAvailable() { + ShortNameProvider provider = () -> "MQTT"; + assertThat(ServiceFailureNotification.shortName(provider)).isEqualTo("MQTT"); + } + + @Test + void shortNameFallsBackToToStringForOtherKeys() { + Object key = new Object() { + @Override public String toString() { return "LOGIN"; } + }; + assertThat(ServiceFailureNotification.shortName(key)).isEqualTo("LOGIN"); + } + +} diff --git a/monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java b/monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java new file mode 100644 index 0000000000..b7051db534 --- /dev/null +++ b/monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java @@ -0,0 +1,183 @@ +/** + * 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.monitoring.notification.incident; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.monitoring.data.notification.AffectedService; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class IncidentManagerTest { + + private RecordingTransport transport; + private IncidentManager manager; + + @BeforeEach + void setUp() { + transport = new RecordingTransport(); + manager = new IncidentManager(transport, 3600L, "tbqa", false); + } + + @AfterEach + void tearDown() { + manager.shutdown(); + } + + @Test + void formatDurationRendersMinutesAndHours() { + assertThat(IncidentManager.formatDuration(Duration.ofSeconds(30))).isEqualTo("0m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(5))).isEqualTo("5m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(59))).isEqualTo("59m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(60))).isEqualTo("1h"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(75))).isEqualTo("1h15m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(120))).isEqualTo("2h"); + } + + @Test + void firstFailureOpensIncidentAndPostsHeaderAndReply() { + manager.sendAlert("CoAP failure message", + List.of(AffectedService.failing("CoAP", 1))); + + assertThat(transport.incidents).hasSize(1); + assertThat(transport.incidents.get(0)).contains(":rotating_light:").contains(":red_circle: CoAP (1)"); + assertThat(transport.replies).hasSize(1); + assertThat(transport.replies.get(0).text()).isEqualTo("CoAP failure message"); + } + + @Test + void isolatedRecoveryWithoutActiveIncidentIsIgnored() { + manager.sendAlert("Login is OK", + List.of(AffectedService.recovered("Login"))); + + assertThat(transport.incidents).isEmpty(); + assertThat(transport.replies).isEmpty(); + assertThat(transport.updates).isEmpty(); + } + + @Test + void subsequentFailureUpdatesHeaderAndPostsReply() { + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1))); + manager.sendAlert("CoAP repeat", List.of(AffectedService.failing("CoAP", 3))); + + assertThat(transport.incidents).hasSize(1); + assertThat(transport.replies).hasSize(2); + assertThat(transport.updates).hasSize(1); + assertThat(transport.updates.get(0).text()).contains(":red_circle: CoAP (3)"); + } + + @Test + void recoveryAfterFailureMovesServiceToGreenAndKeepsFailureCount() { + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 4))); + manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP"))); + + assertThat(transport.updates).hasSize(1); + String updated = transport.updates.get(0).text(); + assertThat(updated).contains(":large_green_circle: CoAP (4)").doesNotContain(":red_circle:"); + } + + @Test + void highLatencyIsTrackedAsYellow() { + manager.sendAlert("high latency", + List.of(AffectedService.highLatency("logInLatency"))); + + assertThat(transport.incidents.get(0)).contains(":large_yellow_circle: logInLatency"); + } + + @Test + void repeatingSameFailureCountDoesNotTriggerRedundantUpdate() { + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 3))); + manager.sendAlert("CoAP still failing", List.of(AffectedService.failing("CoAP", 3))); + + assertThat(transport.updates).isEmpty(); + assertThat(transport.replies).hasSize(2); + } + + @Test + void fullLifecycleStartFailRecoverResolve() { + manager.sendAlert("Login failure", List.of(AffectedService.failing("Login", 1))); + manager.sendAlert("WS failure", List.of(AffectedService.failing("WS Connect", 1))); + manager.sendAlert("Login is OK", List.of(AffectedService.recovered("Login"))); + + assertThat(transport.incidents).hasSize(1); + + manager.resolveIncident(); + + assertThat(transport.updates).last() + .extracting(RecordingTransport.Message::text) + .asString() + .contains(":white_check_mark:") + .contains(":red_circle: WS Connect") + .contains(":large_green_circle: Login (1)"); + } + + @Test + void resolveWithoutActiveIncidentIsNoOp() { + manager.resolveIncident(); + assertThat(transport.updates).isEmpty(); + } + + @Test + void doesNotAutoResolveWhileServicesAreStillFailing() throws Exception { + manager.shutdown(); + transport = new RecordingTransport(); + manager = new IncidentManager(transport, 1L, "tbqa", false); + + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1))); + Thread.sleep(1500); + + assertThat(transport.updates) + .extracting(RecordingTransport.Message::text) + .noneMatch(t -> t.contains(":white_check_mark:")); + + manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP"))); + Thread.sleep(1500); + + assertThat(transport.updates) + .extracting(RecordingTransport.Message::text) + .anyMatch(t -> t.contains(":white_check_mark:")); + } + + private static class RecordingTransport implements IncidentTransport { + private final AtomicInteger threadCounter = new AtomicInteger(); + final java.util.List incidents = new java.util.ArrayList<>(); + final java.util.List replies = new java.util.ArrayList<>(); + final java.util.List updates = new java.util.ArrayList<>(); + + @Override + public String postIncident(String text) { + incidents.add(text); + return "thread-" + threadCounter.incrementAndGet(); + } + + @Override + public void postThreadReply(String threadId, String text) { + replies.add(new Message(threadId, text)); + } + + @Override + public void updateIncident(String threadId, String text) { + updates.add(new Message(threadId, text)); + } + + record Message(String threadId, String text) {} + } +} diff --git a/msa/js-executor/config/custom-environment-variables.yml b/msa/js-executor/config/custom-environment-variables.yml index 6a98361c72..9923fe4ed3 100644 --- a/msa/js-executor/config/custom-environment-variables.yml +++ b/msa/js-executor/config/custom-environment-variables.yml @@ -34,7 +34,7 @@ kafka: partitions_consumed_concurrently: "TB_KAFKA_PARTITIONS_CONSUMED_CONCURRENTLY" # (EXPERIMENTAL) increase this value if you are planning to handle more than one partition (scale up, scale down) - this will decrease the latency requestTimeout: "TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS" connectionTimeout: "TB_KAFKA_CONNECTION_TIMEOUT_MS" - compression: "TB_QUEUE_KAFKA_COMPRESSION" # gzip or uncompressed + compression: "TB_QUEUE_KAFKA_COMPRESSION" # gzip, lz4 or none topic_properties: "TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES" use_confluent_cloud: "TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD" client_id: "KAFKA_CLIENT_ID" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml index 3a08495069..8acaaaaba9 100644 --- a/msa/js-executor/config/default.yml +++ b/msa/js-executor/config/default.yml @@ -40,7 +40,7 @@ kafka: partitions_consumed_concurrently: "1" # (EXPERIMENTAL) increase this value if you are planning to handle more than one partition (scale up, scale down) - this will decrease the latency requestTimeout: "30000" # Time in milliseconds to wait for a response to a Kafka request before timing out connectionTimeout: "1000" # Time in milliseconds to wait when establishing a connection to a Kafka broker - compression: "none" # Message compression codec for the producer: none, gzip + compression: "none" # Message compression codec for the producer: gzip, lz4 or none topic_properties: "retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1" # Semicolon-separated Kafka topic configuration properties applied on topic creation use_confluent_cloud: false # Set to true to enable Confluent Cloud-specific configuration (SSL + SASL) client_id: "kafkajs" # Kafka client identifier; inject the pod name to easily identify the client via kafka-consumer-groups.sh 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/package.json b/msa/js-executor/package.json index b857f1e085..7084794b6b 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -6,13 +6,14 @@ "main": "server.ts", "bin": "server.js", "scripts": { - "pkg": "tsc && pkg -t node22-linux-x64,node22-win-x64 --out-path ./target ./target/src && node install.js", + "pkg": "tsc && pkg -t node22-linux-x64 --output ./target/thingsboard-js-executor-linux ./target/src && pkg -t node22-win-x64 --no-bytecode --public-packages \"*\" --public --output ./target/thingsboard-js-executor-win.exe ./target/src && node install.js", "test": "echo \"Error: no test specified\" && exit 1", "start": "nodemon --watch '.' --ext 'ts' --exec 'ts-node server.ts'", "start-prod": "nodemon --watch '.' --ext 'ts' --exec 'NODE_ENV=production ts-node server.ts'", "build": "tsc" }, "dependencies": { + "@2l/kafkajs-lz4": "^1.3.2", "config": "^4.1.1", "express": "^5.1.0", "js-yaml": "^4.1.1", @@ -46,7 +47,8 @@ }, "pkg": { "assets": [ - "node_modules/config/**/*.*" + "node_modules/config/**/*.*", + "node_modules/@antoniomuso/lz4-napi-*/**/*.node" ] } } diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index a60111f7f8..6b3ed4ab8f 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -52,6 +52,29 @@ exe provided + + + org.thingsboard.msa + web-ui + ${project.version} + pom + provided + + + * + * + + + @@ -70,7 +93,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 @@ -90,7 +113,7 @@ compile - run pkg + --mutex network run pkg diff --git a/msa/js-executor/queue/kafkaTemplate.ts b/msa/js-executor/queue/kafkaTemplate.ts index 4527e9e1ce..175abd80f4 100644 --- a/msa/js-executor/queue/kafkaTemplate.ts +++ b/msa/js-executor/queue/kafkaTemplate.ts @@ -21,6 +21,7 @@ import { JsInvokeMessageProcessor } from '../api/jsInvokeMessageProcessor' import { IQueue } from './queue.models'; import { Admin, + CompressionCodecs, CompressionTypes, Consumer, Kafka, @@ -46,7 +47,31 @@ export class KafkaTemplate implements IQueue { private linger = Number(config.get('kafka.linger_ms')); private requestTimeout = Number(config.get('kafka.requestTimeout')); private connectionTimeout = Number(config.get('kafka.connectionTimeout')); - private compressionType = (config.get('kafka.compression') === "gzip") ? CompressionTypes.GZIP : CompressionTypes.None; + private compressionType = this.resolveCompressionType(config.get('kafka.compression')); + + private resolveCompressionType(compression: string): CompressionTypes { + switch (compression) { + case 'gzip': + return CompressionTypes.GZIP; + case 'lz4': { + // Load the LZ4 codec lazily so users who don't enable LZ4 don't take a hard + // dependency on the lz4-napi native binary (e.g. inside pkg-built executables). + // The package re-assigns module.exports = LZ4Codec, which wipes the __esModule + // flag and the .default property — so accept either shape. + const lz4Module = require('@2l/kafkajs-lz4'); + const LZ4Codec = lz4Module.default || lz4Module; + CompressionCodecs[CompressionTypes.LZ4] = new LZ4Codec().codec; + return CompressionTypes.LZ4; + } + case 'none': + return CompressionTypes.None; + default: + if (isNotEmptyStr(compression)) { + this.logger.warn('Unknown kafka.compression value "%s"; falling back to no compression. Supported values: gzip, lz4, none.', compression); + } + return CompressionTypes.None; + } + } private partitionsConsumedConcurrently = Number(config.get('kafka.partitions_consumed_concurrently')); private kafkaClient: Kafka; diff --git a/msa/js-executor/yarn.lock b/msa/js-executor/yarn.lock index 7e2fe58a13..5e3330e0d7 100644 --- a/msa/js-executor/yarn.lock +++ b/msa/js-executor/yarn.lock @@ -2,6 +2,78 @@ # yarn lockfile v1 +"@2l/kafkajs-lz4@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@2l/kafkajs-lz4/-/kafkajs-lz4-1.3.2.tgz#25259f693a709816ef4eb62514c1ce1bab80502f" + integrity sha512-yq5dx4CbL2sofWXKuadyty3ZKRcZyqe5iiuuOWjZgtIed9MBZ2Wqe5hQN18+a5XVfR4SFQGVISb/a0oTjdPjwQ== + dependencies: + lz4-napi "^2.8.0" + +"@antoniomuso/lz4-napi-android-arm-eabi@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-android-arm-eabi/-/lz4-napi-android-arm-eabi-2.9.0.tgz#5ea67847f0a761c9aec22a31212d0e429ca01fb2" + integrity sha512-aeT/9SoWq7rnmzssWuCKUPaxVt3fzE9q+xq/ZHbnUSmrm8/EhLOACMvQeCOnL0IZsmPh8EpuwIE1TZyM9iQPRA== + +"@antoniomuso/lz4-napi-android-arm64@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-android-arm64/-/lz4-napi-android-arm64-2.9.0.tgz#64cb0aac70267bb071bba2d8e382301e058811ca" + integrity sha512-ibQ0qiEvmljXAM97IgOZfh+PeiSQ0Rqf2HErJlZPVm2v4GVJoB67v21v1TUydqNNV5L8bwufVoZ90nheL8X9ZA== + +"@antoniomuso/lz4-napi-darwin-arm64@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-darwin-arm64/-/lz4-napi-darwin-arm64-2.9.0.tgz#669e48b165af11cec20581acc2caa0d1c0f84472" + integrity sha512-1su4K1MWa4bcWoZlHajv+luGmFDV1JwIsvjtDF+0HhUveSDPP+8A4Z34zOZidURIr08Sl7M7ViPth6ZQ9SqnAA== + +"@antoniomuso/lz4-napi-darwin-x64@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-darwin-x64/-/lz4-napi-darwin-x64-2.9.0.tgz#a00ddf021772e26bd01b3ca5e3025f11d1667edf" + integrity sha512-8Lnbm2MkdJtiJ/nbcRS9zRyGp3G0sG6D+Y/x1vTP8nZs3/f8tBwYNsjxCQyyXNNyHcYWwVGbk68onP/pyDljOA== + +"@antoniomuso/lz4-napi-freebsd-x64@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-freebsd-x64/-/lz4-napi-freebsd-x64-2.9.0.tgz#fdc2b292489bd6d4e44eb7bb059fb9bd4b860f14" + integrity sha512-k04EMVOjntKDPrdR4Tf8WyNseuk9PTtSGw8WHyp4CTjoR1s+YJxtp9SMnThe5o2q0TATwk8WGYb/Howrp5OMxw== + +"@antoniomuso/lz4-napi-linux-arm-gnueabihf@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-arm-gnueabihf/-/lz4-napi-linux-arm-gnueabihf-2.9.0.tgz#9fcdf93b3ca5aa24469e4bf11dd7100df8c71975" + integrity sha512-H92F8zPZmgy2r8IhCWh3qIBfLp2BQ5cp18RoDXhtGFWwkh+5gVWrZp11IVznrsdgB0QeW0VR7dAMMHg3WLOPfA== + +"@antoniomuso/lz4-napi-linux-arm64-gnu@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-arm64-gnu/-/lz4-napi-linux-arm64-gnu-2.9.0.tgz#9d22e075a9cb60c3cb415428138618ae4a79f3c5" + integrity sha512-25crh0qs/3Rj3fMI8ulYD0DoaKsidUhMBki2aeO69ZK+F8bmQ/e2++FlgJ6f3EgMP5CNxJtnZXKhPOraQWjwAw== + +"@antoniomuso/lz4-napi-linux-arm64-musl@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-arm64-musl/-/lz4-napi-linux-arm64-musl-2.9.0.tgz#d957bce3accd49199bb6de522f5163efa2068fe4" + integrity sha512-eJtHp38zuLaYI0/cOV/BKcNQiXUBo4GPx53FTf0Y307yUjLsn48LNeN0vD28Ct9YrbUae3bQvMD5AD86She0ww== + +"@antoniomuso/lz4-napi-linux-x64-gnu@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-x64-gnu/-/lz4-napi-linux-x64-gnu-2.9.0.tgz#c86567d5aa23863059afbee829fb9f45895d3869" + integrity sha512-mDjS4dyjRKaZQcAP71SphkYH5r3kufB30ih/VETVu/br2toCfBk6Zr1xhL1r+L7FaVAFzF62B7h30CiqrN0Awg== + +"@antoniomuso/lz4-napi-linux-x64-musl@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-x64-musl/-/lz4-napi-linux-x64-musl-2.9.0.tgz#7d02612dec7d6247645aa321871b15247da0ce7d" + integrity sha512-pvU7Z7qjkjn17NkddBtBQ7C2iRqjtZ7WJ3Jqrjtj4XxolY3Q0HaYMvWjkWhzb9AKGZbj5y+EHYtbVoZJ2TSQhQ== + +"@antoniomuso/lz4-napi-win32-arm64-msvc@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-win32-arm64-msvc/-/lz4-napi-win32-arm64-msvc-2.9.0.tgz#c901bfec718303ed86867129ecc0371eaa883517" + integrity sha512-aioLlbpJl0QPEXLXhh2bzyitc3T7Jot3f1ap6WdKiRa+CIjMHXw1nxJXy07MLXif10r+qVZr86ic8dvwErgqEQ== + +"@antoniomuso/lz4-napi-win32-ia32-msvc@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-win32-ia32-msvc/-/lz4-napi-win32-ia32-msvc-2.9.0.tgz#11e48b18b7923c250735e0130998e5968ae91130" + integrity sha512-VaF4XMTdYb59TsPsiqnWwsNaWKHhgxF33z5p4zg4n0tp20eWozl76hn8B+aXthSs40W0W1N97QhxxV4oXGd8cg== + +"@antoniomuso/lz4-napi-win32-x64-msvc@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-win32-x64-msvc/-/lz4-napi-win32-x64-msvc-2.9.0.tgz#20d9f71a638f3277cd0b7662e966f90e53d98af8" + integrity sha512-wfA8ShO3eGLxJ1LDwXJo87XL2D4NkMJV1pfHPvLZpD0MWb9u8VfgS+gKK5YhT7XKjzVdeIna9jgFdn2HBnZBxA== + "@babel/generator@^7.23.0": version "7.28.3" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e" @@ -100,6 +172,18 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@napi-rs/triples@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@napi-rs/triples/-/triples-1.2.0.tgz#bcd9c936acb93890e7015818e0181f3db421aafa" + integrity sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA== + +"@node-rs/helper@^1.3.3": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@node-rs/helper/-/helper-1.6.0.tgz#83e5381de6e898d0b8c92178bb8d897d619e3a3a" + integrity sha512-2OTh/tokcLA1qom1zuCJm2gQzaZljCCbtX1YCrwRVd/toz7KxaDRFeLTAPwhs8m9hWgzrBn5rShRm6IaZofCPw== + dependencies: + "@napi-rs/triples" "^1.2.0" + "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" @@ -998,6 +1082,27 @@ long@^5.3.2: resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== +lz4-napi@^2.8.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/lz4-napi/-/lz4-napi-2.9.0.tgz#4a700974a1154f82b3c0e1b7030fe63140051e7d" + integrity sha512-ZOWqxBMIK5768aD20tYn5B6Pp9WPM9UG/LHk8neG9p0gC1DtjdzhTtlkxhAjvTRpmJvMtnnqLKlT+COlqAt9cQ== + dependencies: + "@node-rs/helper" "^1.3.3" + optionalDependencies: + "@antoniomuso/lz4-napi-android-arm-eabi" "2.9.0" + "@antoniomuso/lz4-napi-android-arm64" "2.9.0" + "@antoniomuso/lz4-napi-darwin-arm64" "2.9.0" + "@antoniomuso/lz4-napi-darwin-x64" "2.9.0" + "@antoniomuso/lz4-napi-freebsd-x64" "2.9.0" + "@antoniomuso/lz4-napi-linux-arm-gnueabihf" "2.9.0" + "@antoniomuso/lz4-napi-linux-arm64-gnu" "2.9.0" + "@antoniomuso/lz4-napi-linux-arm64-musl" "2.9.0" + "@antoniomuso/lz4-napi-linux-x64-gnu" "2.9.0" + "@antoniomuso/lz4-napi-linux-x64-musl" "2.9.0" + "@antoniomuso/lz4-napi-win32-arm64-msvc" "2.9.0" + "@antoniomuso/lz4-napi-win32-ia32-msvc" "2.9.0" + "@antoniomuso/lz4-napi-win32-x64-msvc" "2.9.0" + make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" diff --git a/msa/pom.xml b/msa/pom.xml index f852773b8c..4e09ad0e0c 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -44,7 +44,16 @@ - + tb web-ui vc-executor diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile index 59e3a2898f..f3cc0a5d66 100644 --- a/msa/tb/docker-cassandra/Dockerfile +++ b/msa/tb/docker-cassandra/Dockerfile @@ -57,6 +57,10 @@ RUN echo "Previously installed font libraries:" \ COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ +# Keep base image's customized conffiles (e.g. /etc/java-17-openjdk/security/java.security) +# when apt upgrades openjdk-17-jre-headless transitively as cassandra's java11-runtime provider; +# without this dpkg blocks on a non-interactive conffile prompt and the build fails. +ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends wget nmap procps gnupg2 \ && mkdir -p /etc/apt/keyrings \ @@ -65,7 +69,9 @@ RUN apt-get update \ && wget -q https://downloads.apache.org/cassandra/KEYS -O- | gpg --dearmor -o /etc/apt/keyrings/cassandra.gpg \ && echo "deb [signed-by=/etc/apt/keyrings/cassandra.gpg] https://debian.cassandra.apache.org 40x main" | tee /etc/apt/sources.list.d/cassandra.sources.list > /dev/null \ && apt-get update \ - && apt-get install -y --no-install-recommends cassandra cassandra-tools postgresql-${PG_MAJOR} \ + && apt-get install -y --no-install-recommends \ + -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ + cassandra cassandra-tools postgresql-${PG_MAJOR} \ && rm -rf /var/lib/apt/lists/* \ && apt-get purge -y --auto-remove \ && sed -i.old '/ulimit/d' /etc/init.d/cassandra \ diff --git a/msa/vc-executor/src/main/resources/tb-vc-executor.yml b/msa/vc-executor/src/main/resources/tb-vc-executor.yml index 188583dc7e..024504f3c1 100644 --- a/msa/vc-executor/src/main/resources/tb-vc-executor.yml +++ b/msa/vc-executor/src/main/resources/tb-vc-executor.yml @@ -77,7 +77,7 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record 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/package.json b/msa/web-ui/package.json index 9e9b643402..f93bc52c9b 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -6,7 +6,7 @@ "main": "server.ts", "bin": "server.js", "scripts": { - "pkg": "tsc && pkg -t node22-linux-x64,node22-win-x64 --out-path ./target ./target/src && node install.js", + "pkg": "tsc && pkg -t node22-linux-x64 --output ./target/thingsboard-web-ui-linux ./target/src && pkg -t node22-win-x64 --no-bytecode --public-packages \"*\" --public --output ./target/thingsboard-web-ui-win.exe ./target/src && node install.js", "test": "echo \"Error: no test specified\" && exit 1", "start": "nodemon --watch '.' --ext 'ts' --exec 'WEB_FOLDER=./target/web ts-node server.ts'", "start-prod": "nodemon --watch '.' --ext 'ts' --exec 'WEB_FOLDER=./target/web NODE_ENV=production ts-node server.ts'", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 753cdef6c8..fb0eb2c066 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 @@ -99,7 +99,7 @@ compile - run pkg + --mutex network run pkg diff --git a/msa/web-ui/yarn.lock b/msa/web-ui/yarn.lock index f421a62684..033aeac686 100644 --- a/msa/web-ui/yarn.lock +++ b/msa/web-ui/yarn.lock @@ -774,9 +774,9 @@ fn.name@1.x.x: integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== follow-redirects@^1.0.0: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== forwarded@0.2.0: version "0.2.0" diff --git a/pom.xml b/pom.xml index 4286678475..cf1259ea5a 100755 --- a/pom.xml +++ b/pom.xml @@ -62,18 +62,22 @@ ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 4.4.0-SNAPSHOT - 3.5.12 - 2.21.1 - 4.1.132.Final + 4.4.0 + 3.5.14 + + 3.5.13 + 3.18.0 + 42.7.11 + 4.1.133.Final 2.4.0-b180830.0359 0.12.5 0.10 4.17.0 4.2.25 - 5.0.4 + 5.0.7 33.1.0-jre - 3.18.0 2.16.1 1.3.1 1.10.0 @@ -90,8 +94,8 @@ 3.9.5 3.25.5 1.76.0 - 1.2.9 - 1.18.44 + 1.2.10 + 1.18.46 1.2.5 1.2.5 1.7.1 @@ -99,11 +103,12 @@ 3.5.4 3.1.4 3.1.4 + 3.5.0 2.8.8TB 2.2.30 0.8 1.19.0 - 1.78.1 + 1.84 2.0.1 org/thingsboard/server/gen/**/*, org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/* @@ -113,8 +118,7 @@ - 3.9.1 - 1.10.1 + 3.9.2 8.10.1 3.5.3 1.12.701 @@ -140,6 +144,7 @@ 3.8.0 1.18.4 1.8.0-TB + 2.5.9 2.38.0 1.24 1.11.0 @@ -167,7 +172,7 @@ 0.4.8 1.0.0 - 1.39.0 + 1.48.0 6.6.0 1.35.0 1.6.1 @@ -621,7 +626,6 @@ org.apache.maven.plugins maven-assembly-plugin - ${pkg.skip.zip} ${pkg.name} ${main.dir}/packaging/${pkg.type}/assembly/windows.xml @@ -634,6 +638,9 @@ single + + ${pkg.skip.zip} + @@ -919,6 +926,12 @@ **/resources/lwm2m/models/** src/main/data/resources/** .claude/** + **/lombok.config + **/eslint.config.mjs + **/config.monitoring + **/valkey-certs/** + **/data/certs/** + **/*.otf JAVADOC_STYLE @@ -971,6 +984,7 @@ org.apache.maven.plugins maven-clean-plugin + ${maven-clean-plugin.version} false @@ -996,15 +1010,6 @@ - - - com.fasterxml.jackson - jackson-bom - ${jackson-bom.version} - pom - import - - io.netty @@ -1276,21 +1281,23 @@ + + + org.springframework.boot + spring-boot-test + ${spring-boot-test.version} + + + org.springframework.boot + spring-boot-test-autoconfigure + ${spring-boot-test.version} + org.apache.kafka kafka-clients ${kafka.version} - - - org.lz4 - lz4-java - - - - - at.yawk.lz4 - lz4-java - ${lz4.version} com.github.springtestdbunit @@ -1365,6 +1372,16 @@ commons-lang3 ${commons-lang3.version} + + org.postgresql + postgresql + ${postgresql.version} + + + org.apache.opennlp + opennlp-tools + ${opennlp-tools.version} + commons-io commons-io @@ -1566,12 +1583,6 @@ org.apache.cassandra cassandra-all ${cassandra-all.version} - - - org.lz4 - lz4-java - - org.testng 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/pom.xml b/rule-engine/rule-engine-components/pom.xml index 5e7cb48970..c1cfcf13c8 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -96,10 +96,6 @@ org.apache.kafka kafka-clients - - at.yawk.lz4 - lz4-java - com.amazonaws aws-java-sdk-sns 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/tools/pom.xml b/tools/pom.xml index ed02dbf836..30b563a498 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -54,10 +54,24 @@ org.apache.cassandra cassandra-all - - - at.yawk.lz4 - lz4-java + + + + org.perfkit.sjk.parsers + sjk-jfr5 + + + org.perfkit.sjk.parsers + sjk-jfr6 + + + org.perfkit.sjk.parsers + sjk-nps + + commons-io diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index c618a921aa..3bf2295ec9 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -175,6 +175,15 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters # Configures the CoAP and CoAP/DTLS server endpoints, including bind addresses, timeouts, and TLS credentials. @@ -283,8 +292,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 5173f5c26d..945a63240a 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -207,6 +207,15 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters # Configures the messaging queue backend (Kafka), topic definitions, partitioning, and per-service consumer settings. @@ -233,7 +242,7 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 2711eae3ec..85068152a6 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -307,6 +307,15 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration properties # Defines the message queue backend (Kafka) and per-topic settings for transport, core, rule-engine, and JS evaluation. @@ -333,8 +342,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 02c451fd69..52d469e249 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -240,6 +240,15 @@ transport: max_wrong_credentials_per_ip: "${TB_TRANSPORT_MAX_WRONG_CREDENTIALS_PER_IP:10}" # Timeout to expire block IP addresses ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters # Defines the messaging queue backend (Kafka), topic names, partitioning, and poll/processing settings for all platform microservices. @@ -266,8 +275,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index 028e9e2714..46b1d8e48b 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -214,8 +214,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 4d001423cd..3dea406f1a 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -13,20 +13,20 @@ }, "private": true, "dependencies": { - "@angular/animations": "20.3.18", + "@angular/animations": "20.3.19", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.18", - "@angular/compiler": "20.3.18", - "@angular/core": "20.3.18", - "@angular/forms": "20.3.18", + "@angular/common": "20.3.19", + "@angular/compiler": "20.3.19", + "@angular/core": "20.3.19", + "@angular/forms": "20.3.19", "@angular/material": "20.2.14", - "@angular/platform-browser": "20.3.18", - "@angular/platform-browser-dynamic": "20.3.18", - "@angular/router": "20.3.18", + "@angular/platform-browser": "20.3.19", + "@angular/platform-browser-dynamic": "20.3.19", + "@angular/router": "20.3.19", "@auth0/angular-jwt": "^5.2.0", "@flowjs/flow.js": "^2.14.1", "@flowjs/ngx-flow": "20.0.2", - "@geoman-io/leaflet-geoman-free": "2.18.3", + "@geoman-io/leaflet-geoman-free": "2.19.3", "@iplab/ngx-color-picker": "^20.0.0", "@mat-datetimepicker/core": "~16.0.1", "@mdi/svg": "^7.4.47", @@ -45,7 +45,7 @@ "angular2-hotkeys": "^16.0.1", "canvas-gauges": "^2.1.7", "core-js": "^3.48.0", - "dayjs": "1.11.19", + "dayjs": "1.11.20", "echarts": "https://github.com/thingsboard/echarts/archive/5.5.2-TB.tar.gz", "flot": "https://github.com/thingsboard/flot.git#0.9-work", "flot.curvedlines": "https://github.com/MichaelZinsmaier/CurvedLines.git#master", @@ -94,13 +94,13 @@ }, "devDependencies": { "@angular-builders/custom-esbuild": "20.0.0", - "@angular-devkit/build-angular": "20.3.22", - "@angular-devkit/core": "20.3.22", - "@angular-devkit/schematics": "20.3.22", - "@angular/build": "20.3.22", - "@angular/cli": "20.3.22", - "@angular/compiler-cli": "20.3.18", - "@angular/language-service": "20.3.18", + "@angular-devkit/build-angular": "20.3.24", + "@angular-devkit/core": "20.3.24", + "@angular-devkit/schematics": "20.3.24", + "@angular/build": "20.3.24", + "@angular/cli": "20.3.24", + "@angular/compiler-cli": "20.3.19", + "@angular/language-service": "20.3.19", "@types/ace-diff": "^2.1.4", "@types/canvas-gauges": "^2.1.8", "@types/flot": "^0.0.36", @@ -139,7 +139,7 @@ "ace-builds": "1.43.6", "tinymce": "6.8.6", "@babel/core": "7.28.3", - "esbuild": "0.25.9", + "esbuild": "0.28.0", "rollup": "4.59.0", "jquery.terminal/**/form-data": ">=4.0.4", "js-beautify/**/minimatch": "^9.0.7" diff --git a/ui-ngx/patches/@angular+build+20.3.22.patch b/ui-ngx/patches/@angular+build+20.3.24.patch similarity index 100% rename from ui-ngx/patches/@angular+build+20.3.22.patch rename to ui-ngx/patches/@angular+build+20.3.24.patch diff --git a/ui-ngx/patches/@angular+core+20.3.18.patch b/ui-ngx/patches/@angular+core+20.3.19.patch similarity index 97% rename from ui-ngx/patches/@angular+core+20.3.18.patch rename to ui-ngx/patches/@angular+core+20.3.19.patch index 12ceb3739d..5295946f7b 100644 --- a/ui-ngx/patches/@angular+core+20.3.18.patch +++ b/ui-ngx/patches/@angular+core+20.3.19.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@angular/core/fesm2022/debug_node.mjs b/node_modules/@angular/core/fesm2022/debug_node.mjs -index 35c61af..d89462b 100755 +index 4f7d936..4a98b2c 100755 --- a/node_modules/@angular/core/fesm2022/debug_node.mjs +++ b/node_modules/@angular/core/fesm2022/debug_node.mjs @@ -9428,13 +9428,13 @@ function findDirectiveDefMatches(tView, tNode) { diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index fa19b204d0..8e16f17653 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 @@ -106,7 +106,7 @@ yarn - run build:prod + --mutex network run build:prod 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/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index 669c47fd78..aaf8844863 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -43,7 +43,7 @@ const emptyUserAuthState: AuthPayload = { intermediateAggregationIntervalInSecForCF: 0, userSettings: initialUserSettings, trendzSettings: initialTrendzSettings, - allowKeyFiltersOrConditions: false + allowKeyFiltersOrConditions: true }; export const initialState: AuthState = { 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/filter/filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts index 6db4a76822..30e5a791b8 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts @@ -41,7 +41,6 @@ export interface FilterDialogData { @Component({ selector: 'tb-filter-dialog', templateUrl: './filter-dialog.component.html', - styleUrls: ['./filter-dialog.component.scss'], standalone: false }) export class FilterDialogComponent extends DialogComponent { 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()) {