diff --git a/TEST_FAST.md b/TEST_FAST.md index eb2013c601..fbea72db9c 100644 --- a/TEST_FAST.md +++ b/TEST_FAST.md @@ -10,6 +10,7 @@ export SUREFIRE_JAVA_OPTS="-Xmx1200m -Xss256k -XX:+ExitOnOutOfMemoryError" mvn clean install -T6 -DskipTests -Dpkg.skip=true mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4 +mvn test -pl='msa/js-executor' mvn test -pl dao -Dparallel=packages -DforkCount=4 mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 diff --git a/application/pom.xml b/application/pom.xml index 0ae03c9725..4114f98759 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard application @@ -289,6 +289,11 @@ rest-client test + + org.thingsboard.client + thingsboard-ce-client + test + org.springframework.security spring-security-test @@ -502,6 +507,105 @@ + + + openapi-spec + + true + none + none + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.thingsboard.server.ThingsboardServerApplication + 9001 + + + + build-info + build-info + + false + + + + openapi-start + start + + false + true + -Xmx1024m + + --spring.config.name=thingsboard + --spring.profiles.active=openapi + + 180 + 2000 + + + + openapi-stop + stop + + false + + + + + + org.springdoc + springdoc-openapi-maven-plugin + 1.4 + + + generate-openapi-spec + generate + + + + http://localhost:8080/v3/api-docs/thingsboard + openapi.json + ${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 + + true + + + + + + jenkins 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 1a1a409c1a..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,11 +15,16 @@ */ 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; +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; import io.swagger.v3.oas.models.OpenAPI; @@ -31,6 +36,7 @@ import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.IntegerSchema; import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.RequestBody; @@ -39,11 +45,12 @@ import io.swagger.v3.oas.models.responses.ApiResponses; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.tags.Tag; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.customizers.OpenApiCustomizer; import org.springdoc.core.customizers.OperationCustomizer; -import org.springdoc.core.customizers.RouterOperationCustomizer; import org.springdoc.core.discoverer.SpringDocParameterNameDiscoverer; +import org.springdoc.core.utils.SpringDocUtils; import org.springdoc.core.models.GroupedOpenApi; import org.springdoc.core.properties.SpringDocConfigProperties; import org.springdoc.core.properties.SwaggerUiConfigProperties; @@ -54,26 +61,35 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; -import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.RequestParam; 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.common.data.security.Authority; 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; +import java.util.ArrayDeque; 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; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @@ -83,16 +99,30 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @Profile("!test") public class SwaggerConfiguration { + @PostConstruct + public void configureModelResolver() { + ModelResolver.enumsAsRef = true; + SpringDocUtils.getConfig().replaceWithSchema(ByteBuffer.class, + new Schema().type("string").format("byte")); + } + public static final String LOGIN_ENDPOINT = "/api/auth/login"; public static final String REFRESH_TOKEN_ENDPOINT = "/api/auth/token"; - private static final String LOGIN_PASSWORD_SCHEME = "HTTP login form"; - private static final String API_KEY_SCHEME = "API key form"; + private static final String LOGIN_PASSWORD_SCHEME = "HttpLoginForm"; + private static final String API_KEY_SCHEME = "ApiKeyForm"; private static final ApiResponses loginResponses = loginResponses(); private static final ApiResponses defaultErrorResponses = defaultErrorResponses(false); private static final ApiResponses defaultPostErrorResponses = defaultErrorResponses(true); + // Populated by mapAwareConverter, consumed by customOpenApiCustomizer. + // 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; @Value("${swagger.security_path_regex}") @@ -137,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) @@ -157,9 +190,9 @@ public class SwaggerConfiguration { .in(SecurityScheme.In.HEADER) .description(""" Enter the API key value with 'ApiKey' prefix in format: **ApiKey ** - + Example: **ApiKey tb_5te51SkLRYpjGrujUGwqkjFvooWBlQpVe2An2Dr3w13wjfxDW** - +
**NOTE**: Use only ONE authentication method at a time. If both are authorized, JWT auth takes the priority.
"""); @@ -213,11 +246,12 @@ public class SwaggerConfiguration { private void addLoginOperation(OpenAPI openAPI) { var operation = new Operation(); operation.summary("Login method to get user JWT token data"); + operation.operationId("login"); operation.description(""" Login method used to authenticate user and get JWT token data. - + Value of the response **token** field can be used as **X-Authorization** header value: - + `X-Authorization: Bearer $JWT_TOKEN_VALUE`."""); var requestBody = new RequestBody().description("Login request") @@ -235,11 +269,12 @@ public class SwaggerConfiguration { private void addRefreshTokenOperation(OpenAPI openAPI) { var operation = new Operation(); operation.summary("Refresh user JWT token data"); + operation.operationId("refreshToken"); operation.description(""" Method to refresh JWT token. Provide a valid refresh token to get a new JWT token. - + The response contains a new token that can be used for authorization. - + `X-Authorization: Bearer $JWT_TOKEN_VALUE`"""); var requestBody = new RequestBody().description("Refresh token request") @@ -260,7 +295,6 @@ public class SwaggerConfiguration { return GroupedOpenApi.builder() .group(groupName) .pathsToMatch(apiPath) - .addRouterOperationCustomizer(routerOperationCustomizer(localSpringDocParameterNameDiscoverer)) .addOperationCustomizer(operationCustomizer()) .addOpenApiCustomizer(customOpenApiCustomizer()) .build(); @@ -270,9 +304,34 @@ 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(); + Schema atomicSchema = switch (cls.getName()) { + case "java.util.concurrent.atomic.AtomicInteger" -> new IntegerSchema().format("int32"); + case "java.util.concurrent.atomic.AtomicLong" -> new IntegerSchema().format("int64"); + case "com.google.common.util.concurrent.AtomicDouble" -> new IntegerSchema().format("double"); + default -> null; + }; + if (atomicSchema != null) { + return atomicSchema; + } + } if (chain.hasNext()) { Schema schema = chain.next().resolve(type, context, chain); - JavaType javaType = Json.mapper().constructType(type.getType()); if (javaType != null) { Class cls = javaType.getRawClass(); if (Map.class.isAssignableFrom(cls)) { @@ -282,6 +341,26 @@ public class SwaggerConfiguration { schema.setProperties(null); } } + } else { + // Precompute property order and own-prop names for this class. + // The actual reordering happens later in the OpenApiCustomizer, + // which has access to the final state of all component schemas + // (including ones where the ModelConverter only sees a $ref). + 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()) { + schemaOwnProps.put(schemaName, ownProps); + } + } catch (Exception e) { + log.debug("Failed to resolve property order for {}: {}", cls.getName(), e.getMessage()); + } } } return schema; @@ -292,45 +371,32 @@ public class SwaggerConfiguration { } private void addDefaultSchemas(OpenAPI openAPI) { - var jsonNodeSchema = ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(JsonNode.class)).schema; - jsonNodeSchema.setType("any"); - //noinspection unchecked - jsonNodeSchema.setExamples(List.of(JacksonUtil.newObjectNode())); - jsonNodeSchema.setDescription("A value representing the any type (object or primitive)"); - openAPI.getComponents() - .addSchemas("JsonNode", jsonNodeSchema) - .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); - } - - private RouterOperationCustomizer routerOperationCustomizer(SpringDocParameterNameDiscoverer localSpringDocParameterNameDiscoverer) { - return (routerOperation, handlerMethod) -> { - String[] pNames = localSpringDocParameterNameDiscoverer.getParameterNames(handlerMethod.getMethod()); - String[] reflectionParametersNames = Arrays.stream(handlerMethod.getMethod().getParameters()).map(java.lang.reflect.Parameter::getName).toArray(String[]::new); - if (pNames == null || Arrays.stream(pNames).anyMatch(Objects::isNull)) { - pNames = reflectionParametersNames; - } - MethodParameter[] parameters = handlerMethod.getMethodParameters(); - List requestParams = new ArrayList<>(); - for (var i = 0; i < parameters.length; i++) { - var methodParameter = parameters[i]; - RequestParam requestParam = methodParameter.getParameterAnnotation(RequestParam.class); - if (requestParam != null) { - String pName = StringUtils.isNotBlank(requestParam.value()) ? requestParam.value() : - pNames[i]; - if (StringUtils.isNotBlank(pName)) { - requestParams.add(pName); - } + Schema errorCodeSchema = new Schema<>() + .type("integer") + .description("Platform error code") + ._enum(Arrays.stream(ThingsboardErrorCode.values()) + .map(ThingsboardErrorCode::getErrorCode) + .collect(Collectors.toList())); + 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); } - } - if (!requestParams.isEmpty()) { - var path = routerOperation.getPath() + "{?" + String.join(",", requestParams) + "}"; - routerOperation.setPath(path); - } - return routerOperation; - }; + }); + } } private OperationCustomizer operationCustomizer() { @@ -347,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 -> { @@ -370,17 +449,137 @@ public class SwaggerConfiguration { }); sortedPaths.setExtensions(paths.getExtensions()); openAPI.setPaths(sortedPaths); + + if (openAPI.getComponents() != null && openAPI.getComponents().getSchemas() != null) { + Map schemas = openAPI.getComponents().getSchemas(); + + // Fix all schemas: if they have additionalProperties but no type, set type to object + schemas.forEach((schemaName, schema) -> { + if (schema.getAdditionalProperties() != null && schema.getType() == null) { + schema.setType("object"); + log.debug("Added type 'object' to schema: {}", schemaName); + } + }); + + // 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); + if (schema.getAllOf() != null) { + List allOf = schema.getAllOf(); + for (Schema allOfElement : allOf) { + replaceInlineOneOfProperties(allOfElement, schemas); + } + } + }); + + // Deduplicate allOf child schemas: remove properties that are already defined + // in the referenced parent schema to avoid duplication (e.g. EntityId children). + schemas.forEach((schemaName, schema) -> { + Set ownProps = schemaOwnProps.getOrDefault(schemaName, Set.of()); + deduplicateAllOfProperties(schema, schemas, ownProps); + }); + + // Reorder properties for all component schemas. This runs after all + // schemas are finalized so it covers schemas the ModelConverter only + // saw as a $ref (e.g. interface-based discriminator types like EntityId). + schemas.forEach((schemaName, schema) -> { + List propOrder = schemaPropertyOrders.getOrDefault(schemaName, List.of()); + 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()) + .forEach(operation -> { + // Request bodies + if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { + replaceInlineOneOfInContent(operation.getRequestBody().getContent(), schemas); + } + // Response bodies + if (operation.getResponses() != null) { + operation.getResponses().values().stream() + .filter(response -> response.getContent() != null) + .forEach(response -> replaceInlineOneOfInContent(response.getContent(), schemas)); + } + }); + } + + // Set JsonNode schema last so model scanning cannot overwrite it + openAPI.getComponents().addSchemas("JsonNode", new Schema<>() + .description("A value representing the any type (object or primitive)") + .example(JacksonUtil.newObjectNode())); + var sortedSchemas = new TreeMap<>(openAPI.getComponents().getSchemas()); openAPI.getComponents().setSchemas(new LinkedHashMap<>(sortedSchemas)); }; } private SecurityRequirement createSecurityRequirement(String schemeName) { - return new SecurityRequirement().addList(schemeName, Arrays.asList( - Authority.SYS_ADMIN.name(), - Authority.TENANT_ADMIN.name(), - Authority.CUSTOMER_USER.name() - )); + return new SecurityRequirement().addList(schemeName, List.of()); } private Tag extractTagFromPath(Map.Entry entry) { @@ -388,9 +587,134 @@ public class SwaggerConfiguration { return tagName != null ? tagFromTagItem(tagName) : null; } + private String findBaseTypeForOneOf(Map schemas, List oneOfSchemas) { + if (oneOfSchemas.isEmpty()) { + return null; + } + + for (Schema oneOfSchema : oneOfSchemas) { + String ref = oneOfSchema.get$ref(); + if (ref == null) { + continue; + } + String refName = ref.substring(ref.lastIndexOf('/') + 1); + + // Check if this entry is itself a base type with discriminator + Schema candidate = schemas.get(refName); + if (candidate != null && candidate.getDiscriminator() != null + && candidate.getDiscriminator().getMapping() != null) { + return refName; + } + + // Check if this subtype is in another schema's discriminator mapping + String baseType = schemas.entrySet().stream() + .filter(entry -> { + Schema schema = entry.getValue(); + if (schema.getDiscriminator() != null && schema.getDiscriminator().getMapping() != null) { + return schema.getDiscriminator().getMapping().values().stream() + .anyMatch(r -> r.endsWith("/" + refName)); + } + return false; + }) + .map(Map.Entry::getKey) + .findFirst() + .orElse(null); + + 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; + } + + + private void replaceInlineOneOfInContent(Content content, Map schemas) { + content.values().forEach(mediaType -> { + Schema schema = mediaType.getSchema(); + if (schema != null && schema.getOneOf() != null && !schema.getOneOf().isEmpty()) { + String baseType = findBaseTypeForOneOf(schemas, schema.getOneOf()); + if (baseType != null) { + Schema refSchema = new Schema<>(); + refSchema.set$ref("#/components/schemas/" + baseType); + mediaType.setSchema(refSchema); + log.debug("Replaced oneOf in content with $ref to {}", baseType); + } + } + }); + } + + @SuppressWarnings("unchecked") + private void replaceInlineOneOfProperties(Schema schema, Map allSchemas) { + if (schema == null || schema.getProperties() == null) { + return; + } + schema.getProperties().forEach((propName, propSchema) -> { + if (propSchema instanceof Schema) { + Schema prop = (Schema) propSchema; + + // Check if additionalProperties has oneOf (polymorphic map values) + if (prop.getAdditionalProperties() instanceof Schema) { + Schema additionalProps = (Schema) prop.getAdditionalProperties(); + if (additionalProps.getOneOf() != null && !additionalProps.getOneOf().isEmpty()) { + String baseType = findBaseTypeForOneOf(allSchemas, additionalProps.getOneOf()); + if (baseType != null) { + Schema refSchema = new Schema<>(); + refSchema.set$ref("#/components/schemas/" + baseType); + prop.setAdditionalProperties(refSchema); + 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 + if (prop.getOneOf() != null && !prop.getOneOf().isEmpty()) { + String baseType = findBaseTypeForOneOf(allSchemas, prop.getOneOf()); + if (baseType != null) { + Schema refSchema = new Schema<>(); + refSchema.set$ref("#/components/schemas/" + baseType); + if (prop.getDescription() != null) { + refSchema.setDescription(prop.getDescription()); + } + if (prop.getReadOnly() != null) { + refSchema.setReadOnly(prop.getReadOnly()); + } + schema.getProperties().put(propName, refSchema); + log.debug("Replaced oneOf with $ref to {} in property {}", baseType, propName); + } + } + } + }); + } + private String tagItemFromPathItem(PathItem item) { var operations = item.readOperationsMap().values(); - var operation = operations.stream().findAny(); + var operation = operations.stream().findFirst(); if (operation.isPresent()) { var tags = operation.get().getTags(); if (tags != null && !tags.isEmpty()) { @@ -480,28 +804,34 @@ public class SwaggerConfiguration { private static ApiResponses loginErrorResponses() { ApiResponses apiResponses = new ApiResponses(); - apiResponses.addApiResponse("401", errorResponse("Unauthorized", - Map.of( - "bad-credentials", errorExample("Bad credentials", - ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), - "token-expired", errorExample("JWT token expired", - ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED)), - "account-disabled", errorExample("Disabled account", - ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), - "account-locked", errorExample("Locked account", - ThingsboardErrorResponse.of("User account is locked due to security policy", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), - "authentication-failed", errorExample("General authentication error", - ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)) - ) - )); - var credentialsExpiredSchema = new Schema().$ref("#/components/schemas/ThingsboardCredentialsExpiredResponse"); - apiResponses.addApiResponse("401 ", errorResponse("Unauthorized (**Expired credentials**)", - Map.of( - "credentials-expired", errorExample("Expired credentials", - ThingsboardCredentialsExpiredResponse.of("User password expired!", StringUtils.randomAlphanumeric(30))) - ), - credentialsExpiredSchema + Map unauthorizedExamples = new LinkedHashMap<>(); + + unauthorizedExamples.put("bad-credentials", errorExample("Bad credentials", + ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("token-expired", errorExample("JWT token expired", + ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("account-disabled", errorExample("Disabled account", + ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("account-locked", errorExample("Locked account", + ThingsboardErrorResponse.of("User account is locked due to security policy", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("authentication-failed", errorExample("General authentication error", + ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("credentials-expired", errorExample("Expired credentials", + ThingsboardCredentialsExpiredResponse.of("User password expired!", "udgDQOpS1Q4ZFEL8qHF9s8cSKQ7d1h"))); + + Schema unauthorizedSchema = new Schema<>(); + unauthorizedSchema.oneOf(List.of( + new Schema().$ref("#/components/schemas/ThingsboardErrorResponse"), + new Schema().$ref("#/components/schemas/ThingsboardCredentialsExpiredResponse") )); + + apiResponses.addApiResponse("401", errorResponse("Unauthorized", unauthorizedExamples, unauthorizedSchema)); + return apiResponses; } @@ -520,10 +850,389 @@ public class SwaggerConfiguration { return new ApiResponse().description(description).content(content); } + /** + * Recursively collects all property names reachable from {@code schemaName}, walking the + * ancestor chain through allOf $ref entries (to handle multi-level inheritance). + * {@code visited} prevents infinite loops in case of circular references. + */ + @SuppressWarnings("unchecked") + private void collectAllProperties(String schemaName, Map allSchemas, + Set result, Set visited) { + if (!visited.add(schemaName)) { + return; + } + Schema schema = allSchemas.get(schemaName); + if (schema == null) { + return; + } + if (schema.getProperties() != null) { + result.addAll(schema.getProperties().keySet()); + } + if (schema.getAllOf() != null) { + for (Schema allOfElement : schema.getAllOf()) { + String ref = allOfElement.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + collectAllProperties(refName, allSchemas, result, visited); + } else if (allOfElement.getProperties() != null) { + result.addAll(allOfElement.getProperties().keySet()); + } + } + } + } + + 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) { + return; + } + + // Collect properties defined in any $ref'd parent within the allOf, recursively + // walking the ancestor chain (each parent may itself use allOf to extend a grandparent). + Set parentProperties = new LinkedHashSet<>(); + for (Schema allOfElement : schema.getAllOf()) { + String ref = allOfElement.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + collectAllProperties(refName, allSchemas, parentProperties, new LinkedHashSet<>()); + } + } + + if (parentProperties.isEmpty()) { + return; + } + + // Properties to strip: in parent schema AND not declared as own-class fields. + // This removes inherited properties (from superclasses or pure interface getters) + // while keeping properties the class declares as its own fields. + Set toStrip = new LinkedHashSet<>(parentProperties); + toStrip.removeAll(ownProps); + + if (toStrip.isEmpty()) { + return; + } + + // Strip from inline (non-$ref) allOf elements + schema.getAllOf().removeIf(allOfElement -> { + if (allOfElement.get$ref() != null) { + return false; + } + if (allOfElement.getProperties() != null) { + Map filtered = new LinkedHashMap<>(allOfElement.getProperties()); + filtered.keySet().removeAll(toStrip); + allOfElement.setProperties(filtered.isEmpty() ? null : filtered); + } + return allOfElement.getProperties() == null + && allOfElement.getRequired() == null + && allOfElement.getType() == null; + }); + + // Remove stripped properties from the schema's required list + if (schema.getRequired() != null) { + List required = new ArrayList<>(schema.getRequired()); + required.removeAll(toStrip); + schema.setRequired(required.isEmpty() ? null : required); + } + } + + /** + * Computes the schema name that swagger-core will use for the given JavaType. + * For simple types, this is just the class simple name (e.g. {@code Device}). + * For parameterized types, type parameter names are appended + * (e.g. {@code PageData} becomes {@code PageDataDevice}). + * This matches the naming convention used by swagger-core's {@code TypeNameResolver}. + */ + private static String resolveSchemaName(JavaType javaType) { + 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); + if (param != null) { + sb.append(param.getRawClass().getSimpleName()); + } + } + } + return sb.toString(); + } + + /** + * Returns the JSON property names that are backed by fields declared directly in {@code cls} + * (not inherited from a superclass). Used to distinguish "own" from "inherited" properties + * when deduplicating allOf inline elements. + */ + private static Set computeOwnPropNames(Class cls, com.fasterxml.jackson.databind.BeanDescription beanDesc) { + Map allFieldToJson = new LinkedHashMap<>(); + for (var prop : beanDesc.findProperties()) { + if (prop.getField() != null && prop.couldSerialize()) { + allFieldToJson.put(prop.getField().getName(), prop.getName()); + } + } + Set own = new LinkedHashSet<>(); + for (Field f : cls.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + String jsonName = allFieldToJson.get(f.getName()); + if (jsonName != null) own.add(jsonName); + } + return own; + } + + @SuppressWarnings("unchecked") + private static void reorderSchemaProperties(Schema schema, List propOrder) { + if (schema.getProperties() != null && schema.getProperties().size() > 1) { + schema.setProperties(reorderProperties(schema.getProperties(), propOrder)); + } + if (schema.getAllOf() != null) { + for (Schema allOfElement : schema.getAllOf()) { + if (allOfElement.get$ref() != null) continue; + if (allOfElement.getProperties() != null && allOfElement.getProperties().size() > 1) { + allOfElement.setProperties(reorderProperties(allOfElement.getProperties(), propOrder)); + } + } + } + } + + private static LinkedHashMap reorderProperties(Map current, List propOrder) { + var reordered = new LinkedHashMap(); + for (String name : propOrder) { + Schema prop = current.get(name); + if (prop != null) reordered.put(name, prop); + } + // Any properties not covered by propOrder are appended + // alphabetically to guarantee a deterministic stable order. + new TreeMap<>(current).forEach((k, v) -> reordered.putIfAbsent(k, v)); + return reordered; + } + + /** + * Resolves the property ordering for a schema class. + * + *

Returns a list of JSON property names in the order they should appear in the + * OpenAPI schema. The caller uses this list to reorder the schema's property map; + * any properties not present in the returned list are appended alphabetically + * by the caller's {@code TreeMap} fallback, guaranteeing a stable, deterministic order. + * + *

Resolution strategy (first match wins): + *

    + *
  1. If {@code @JsonPropertyOrder} with an explicit {@code value()} is found on the + * class or any interface in its ancestry, that list is returned as-is. Note: if the + * annotation lists only a subset of fields, those fields are ordered first and the + * remaining properties fall through to the caller's alphabetical fallback — consistent + * with Jackson's own behaviour for partial {@code @JsonPropertyOrder}.
  2. + *
  3. Otherwise, field-backed properties are returned in declaration order (superclass + * fields first). Getter-only properties are intentionally excluded to avoid + * non-deterministic ordering across restarts.
  4. + *
+ */ + private static List resolvePropertyOrder(Class cls, com.fasterxml.jackson.databind.BeanDescription beanDesc) { + // If an explicit @JsonPropertyOrder is present on the class or any interface in its + // ancestry, honour it directly. Walk up the class hierarchy; for each class also walk + // the full interface hierarchy (including super-interfaces) via BFS. + for (Class c = cls; c != null && c != Object.class; c = c.getSuperclass()) { + JsonPropertyOrder propOrder = c.getAnnotation(JsonPropertyOrder.class); + if (propOrder != null && !propOrder.alphabetic() && propOrder.value().length > 0) { + return Arrays.asList(propOrder.value()); + } + Deque> ifaceQueue = new ArrayDeque<>(Arrays.asList(c.getInterfaces())); + Set> visitedIfaces = new LinkedHashSet<>(); + while (!ifaceQueue.isEmpty()) { + Class iface = ifaceQueue.poll(); + if (!visitedIfaces.add(iface)) continue; + propOrder = iface.getAnnotation(JsonPropertyOrder.class); + if (propOrder != null && !propOrder.alphabetic() && propOrder.value().length > 0) { + return Arrays.asList(propOrder.value()); + } + ifaceQueue.addAll(Arrays.asList(iface.getInterfaces())); + } + } + + // Map backing field names to their JSON property names (respects @JsonProperty) + Map fieldToJsonName = new LinkedHashMap<>(); + for (var prop : beanDesc.findProperties()) { + 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()); + } + } + } + + // Walk class hierarchy (superclass first) to get field declaration order + List> hierarchy = new ArrayList<>(); + for (Class c = cls; c != null && c != Object.class; c = c.getSuperclass()) { + hierarchy.add(0, c); + } + List ordered = new ArrayList<>(); + for (Class c : hierarchy) { + for (Field f : c.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + String jsonName = fieldToJsonName.get(f.getName()); + if (jsonName != null) ordered.add(jsonName); + } + } + + // Return only field-backed properties in declaration order. + // Getter-only properties (no backing field) are intentionally excluded: their set can vary + // between restarts (e.g. Optional-typed getters depend on Jackson module registration order), + // so including them here would make their position non-deterministic when some are in orderedNames + // and others are only in the schema map. The converter's TreeMap fallback handles ALL + // non-field-backed properties together in one alphabetical pass, guaranteeing stable order. + return ordered; + } + private static Example errorExample(String summary, ThingsboardErrorResponse example) { + var node = (ObjectNode) JacksonUtil.valueToTree(example); + node.put("timestamp", 1609459200000L); return new Example() .summary(summary) - .value(example); + .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/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 0b04a6939f..e4394bace3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -417,7 +417,7 @@ public class AdminController extends BaseController { "provider sends authorization code to specified redirect uri.)") @PreAuthorize("hasAuthority('SYS_ADMIN')") @GetMapping(value = "/mail/oauth2/authorize", produces = "application/text") - public String getAuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { + public String getMailOAuth2AuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { String state = StringUtils.generateSafeToken(); if (request.getParameter(PREV_URI_PATH_PARAMETER) != null) { CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, request.getParameter(PREV_URI_PATH_PARAMETER), 180); @@ -442,7 +442,7 @@ public class AdminController extends BaseController { } @GetMapping(value = "/mail/oauth2/code", params = {"code", "state"}) - public void codeProcessingUrl( + public void handleMailOAuth2Callback( @RequestParam(value = "code") String code, @RequestParam(value = "state") String state, HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException { Optional prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); 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/AlarmController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java index 8f7c8bbbd1..0814e94700 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java @@ -227,12 +227,12 @@ public class AlarmController extends BaseController { return tbAlarmService.unassign(alarm, System.currentTimeMillis(), getCurrentUser()); } - @ApiOperation(value = "Get Alarms (getAlarms)", + @ApiOperation(value = "Get Alarms (getAlarmsByEntity)", notes = "Returns a page of alarms for the selected entity. Specifying both parameters 'searchStatus' and 'status' at the same time will cause an error. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/alarm/{entityType}/{entityId}") - public PageData getAlarms( + public PageData getAlarmsByEntity( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable(ENTITY_TYPE) String strEntityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) 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/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index 09972bd6c2..ae3ea1c60d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -68,7 +68,7 @@ public class ApiKeyController extends BaseController { private final ApiKeyService apiKeyService; @ApiOperation(value = "Save API key for user (saveApiKey)", - notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey '." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey {value}'." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping(value = "/apiKey") public ApiKey saveApiKey( 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 e4158d014f..4a20e2fcd2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -23,6 +24,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +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; @@ -217,8 +219,7 @@ public class AssetController extends BaseController { notes = "Returns a page of assets owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/assets", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/assets") public PageData getTenantAssets( @Parameter(description = PAGE_SIZE_DESCRIPTION) @RequestParam int pageSize, @@ -245,8 +246,7 @@ public class AssetController extends BaseController { notes = "Returns a page of assets info objects owned by tenant. " + PAGE_DATA_PARAMETERS + ASSET_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/assetInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/assetInfos") public PageData getTenantAssetInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION) @RequestParam int pageSize, @@ -274,25 +274,30 @@ public class AssetController extends BaseController { } } - @ApiOperation(value = "Get Tenant Asset (getTenantAsset)", + @Hidden + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/tenant/assets", params = {"assetName"}) + public Asset getTenantAsset(@RequestParam String assetName) throws ThingsboardException { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(assetService.findAssetByTenantIdAndName(tenantId, assetName)); + } + + @ApiOperation(value = "Get Tenant Asset (getTenantAssetByName)", notes = "Requested asset must be owned by tenant that the user belongs to. " + "Asset name is an unique property of asset. So it can be used to identify the asset." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/assets", params = {"assetName"}, method = RequestMethod.GET) - @ResponseBody - public Asset getTenantAsset( + @GetMapping(value = "/tenant/asset") + public Asset getTenantAssetByName( @Parameter(description = ASSET_NAME_DESCRIPTION) @RequestParam String assetName) throws ThingsboardException { - TenantId tenantId = getCurrentUser().getTenantId(); - return checkNotNull(assetService.findAssetByTenantIdAndName(tenantId, assetName)); + return getTenantAsset(assetName); } @ApiOperation(value = "Get Customer Assets (getCustomerAssets)", notes = "Returns a page of assets objects assigned to customer. " + PAGE_DATA_PARAMETERS) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/assets", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/assets") public PageData getCustomerAssets( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -324,8 +329,7 @@ public class AssetController extends BaseController { notes = "Returns a page of assets info objects assigned to customer. " + PAGE_DATA_PARAMETERS + ASSET_INFO_DESCRIPTION) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/assetInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/assetInfos") public PageData getCustomerAssetInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -361,8 +365,7 @@ public class AssetController extends BaseController { @ApiOperation(value = "Get Assets By Ids (getAssetsByIds)", notes = "Requested assets must be owned by tenant or assigned to customer which user is performing the request. ") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/assets", params = {"assetIds"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/assets") public List getAssetsByIds( @Parameter(description = "A list of assets ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) @RequestParam("assetIds") String[] strAssetIds) throws ThingsboardException, ExecutionException, InterruptedException { @@ -383,14 +386,14 @@ public class AssetController extends BaseController { return checkNotNull(assets.get()); } - @ApiOperation(value = "Find related assets (findByQuery)", + @ApiOperation(value = "Find related assets (findAssetsByQuery)", notes = "Returns all assets that are related to the specific entity. " + "The entity id, relation type, asset types, depth of the search, and other query parameters defined using complex 'AssetSearchQuery' object. " + "See 'Model' tab of the Parameters for more info.") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/assets", method = RequestMethod.POST) @ResponseBody - public List findByQuery(@RequestBody AssetSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { + public List findAssetsByQuery(@RequestBody AssetSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query); checkNotNull(query.getParameters()); checkNotNull(query.getAssetTypes()); @@ -469,8 +472,7 @@ public class AssetController extends BaseController { notes = "Returns a page of assets assigned to edge. " + PAGE_DATA_PARAMETERS) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edge/{edgeId}/assets", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/{edgeId}/assets") public PageData getEdgeAssets( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION) @PathVariable(EDGE_ID) String strEdgeId, @@ -516,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/AssetProfileController.java b/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java index 65e324a715..86483413df 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -175,8 +176,7 @@ public class AssetProfileController extends BaseController { notes = "Returns a page of asset profile objects owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/assetProfiles", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/assetProfiles") public PageData getAssetProfiles( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -196,8 +196,7 @@ public class AssetProfileController extends BaseController { notes = "Returns a page of asset profile info objects owned by tenant. " + PAGE_DATA_PARAMETERS + ASSET_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/assetProfileInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/assetProfileInfos") public PageData getAssetProfileInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -227,12 +226,10 @@ public class AssetProfileController extends BaseController { return checkNotNull(assetProfileService.findAssetProfileNamesByTenantId(tenantId, activeOnly)); } - @ApiOperation(value = "Get Asset Profiles By Ids (getAssetProfilesByIds)", - notes = "Requested asset profiles must be owned by tenant which is performing the request. " + - NEW_LINE) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/assetProfileInfos", params = {"assetProfileIds"}) - public List getAssetProfilesByIds( + public List getAssetProfilesByIdsV1( @Parameter(description = "A list of asset profile ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("assetProfileIds") Set assetProfileUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); @@ -243,4 +240,15 @@ public class AssetProfileController extends BaseController { return assetProfileService.findAssetProfilesByIds(tenantId, assetProfileIds); } + @ApiOperation(value = "Get Asset Profiles By Ids (getAssetProfilesByIds)", + notes = "Requested asset profiles must be owned by tenant which is performing the request. " + + NEW_LINE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/assetProfileInfos/list") + public List getAssetProfilesByIds( + @Parameter(description = "A list of asset profile ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("assetProfileIds") Set assetProfileUUIDs) throws ThingsboardException { + return getAssetProfilesByIdsV1(assetProfileUUIDs); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java b/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java index e22c0aa156..6a42f034ba 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java @@ -18,11 +18,10 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; @@ -50,6 +49,7 @@ import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PA 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.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION; @@ -73,8 +73,7 @@ public class AuditLogController extends BaseController { "and users actions (login, logout, etc.) that belong to this customer. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs/customer/{customerId}", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/audit/logs/customer/{customerId}") public PageData getAuditLogsByCustomerId( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -106,8 +105,7 @@ public class AuditLogController extends BaseController { "For example, RPC call to a particular device, or alarm acknowledgment for a specific device, etc. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs/user/{userId}", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/audit/logs/user/{userId}") public PageData getAuditLogsByUserId( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @PathVariable("userId") String strUserId, @@ -138,10 +136,9 @@ public class AuditLogController extends BaseController { notes = "Returns a page of audit logs related to the actions on the targeted entity. " + "Basically, this API call is used to get the full lifecycle of some specific entity. " + "For example to see when a device was created, updated, assigned to some customer, or even deleted from the system. " + - PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs/entity/{entityType}/{entityId}", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/audit/logs/entity/{entityType}/{entityId}") public PageData getAuditLogsByEntityId( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String strEntityType, @@ -173,10 +170,9 @@ public class AuditLogController extends BaseController { @ApiOperation(value = "Get all audit logs (getAuditLogs)", notes = "Returns a page of audit logs related to all entities in the scope of the current user's Tenant. " + - PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/audit/logs") public PageData getAuditLogs( @Parameter(description = PAGE_SIZE_DESCRIPTION) @RequestParam int pageSize, diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index 8bda29e98b..af3a619906 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -136,7 +136,7 @@ public class AuthController extends BaseController { "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Create Password' page and same 'activateToken' specified in the URL parameters. " + "If token is not valid, returns '409 Conflict'. " + "If token is expired, redirects to error page.") - @GetMapping(value = "/noauth/activate", params = {"activateToken"}) + @GetMapping(value = "/noauth/activate") public ResponseEntity checkActivateToken( @Parameter(description = "The activate token string.") @RequestParam(value = "activateToken") String activateToken) { @@ -176,7 +176,7 @@ public class AuthController extends BaseController { "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Reset Password' page and same 'resetToken' specified in the URL parameters. " + "If token is not valid, returns '409 Conflict'. " + "If token is expired, redirects to error page.") - @GetMapping(value = "/noauth/resetPassword", params = {"resetToken"}) + @GetMapping(value = "/noauth/resetPassword") public ResponseEntity checkResetToken( @Parameter(description = "The reset token string.") @RequestParam(value = "resetToken") String resetToken) { 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 bd2a160d5b..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); } @@ -881,10 +893,8 @@ public abstract class BaseController { protected > void logEntityAction(SecurityUser user, EntityType entityType, E entity, E savedEntity, ActionType actionType, Exception e) { EntityId entityId = savedEntity != null ? savedEntity.getId() : emptyId(entityType); - if (!user.isSystemAdmin()) { - entityActionService.logEntityAction(user, entityId, savedEntity != null ? savedEntity : entity, - user.getCustomerId(), actionType, e); - } + entityActionService.logEntityAction(user, entityId, savedEntity != null ? savedEntity : entity, + user.getCustomerId(), actionType, e); } protected > E doSaveAndLog(EntityType entityType, E entity, BiFunction savingFunction) throws Exception { 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 8ef957066b..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,15 +15,15 @@ */ 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; +import io.swagger.v3.oas.annotations.Parameters; +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; @@ -36,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; @@ -61,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; @@ -94,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 @@ -163,27 +146,17 @@ public class CalculatedFieldController extends BaseController { return calculatedField; } - @ApiOperation(value = "Get Calculated Fields by Entity Id (getCalculatedFieldsByEntityId)", - notes = "Fetch the Calculated Fields based on the provided Entity Id." - ) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @GetMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}) - public PageData getCalculatedFieldsByEntityId(@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 = "Calculated field type. If not specified, all types will be returned.") - @RequestParam(required = false) CalculatedFieldType type, - @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 { + 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 { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); checkParameter("entityId", entityIdStr); EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr); @@ -191,8 +164,29 @@ public class CalculatedFieldController extends BaseController { return checkNotNull(tbCalculatedFieldService.findByTenantIdAndEntityId(getTenantId(), entityId, type, pageLink)); } + @ApiOperation(value = "Get Calculated Fields by Entity Id (getCalculatedFieldsByEntityId)", + notes = "Fetch the Calculated Fields based on the provided Entity Id." + ) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @GetMapping(value = "/calculatedField/{entityType}/{entityId}") + public PageData getCalculatedFieldsByEntityId( + @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 = "Calculated field type. If not specified, all types will be returned.") + @RequestParam(required = false) CalculatedFieldType type, + @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 { + return getCalculatedFieldsByEntityIdV1(entityType, entityIdStr, pageSize, page, type, textSearch, sortProperty, sortOrder); + } + @ApiOperation(value = "Get calculated fields (getCalculatedFields)", notes = "Fetch tenant calculated fields based on the filter.") + @Parameters({ + @Parameter(name = "name", description = "Repeatable name query parameter", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @GetMapping(value = "/calculatedFields") public PageData getCalculatedFields(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @@ -205,14 +199,13 @@ public class CalculatedFieldController extends BaseController { @RequestParam(required = false) EntityType entityType, @Parameter(description = "Entities filter. If not specified, calculated fields for entity type filter will be returned.") @RequestParam(required = false) Set entities, - @Parameter(description = "Name filter. To specify multiple names, duplicate 'name' parameter for each name, for example '?name=name1&name=name2") - @RequestParam(required = false) String name, // for Swagger only, retrieved from MultiValueMap params (due to issues when name contains comma) @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, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); SecurityUser user = getCurrentUser(); @@ -289,88 +282,11 @@ public class CalculatedFieldController extends BaseController { notes = TEST_SCRIPT_EXPRESSION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping("/calculatedField/testScript") - public JsonNode 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/ComponentDescriptorController.java b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java index 55dbb8fe04..91569261b7 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java @@ -19,6 +19,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -81,8 +82,7 @@ public class ComponentDescriptorController extends BaseController { notes = "Gets the Component Descriptors using coma separated list of rule node types and optional rule chain type request parameters. " + COMPONENT_DESCRIPTOR_DEFINITION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") - @RequestMapping(value = "/components", params = {"componentTypes"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/components") public List getComponentDescriptorsByTypes( @Parameter(description = "List of types of the Rule Nodes, (ENRICHMENT, FILTER, TRANSFORMATION, ACTION or EXTERNAL)", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("componentTypes") String[] strComponentTypes, 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/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 5ae949d92e..5acf09a779 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -172,8 +173,7 @@ public class CustomerController extends BaseController { notes = "Returns a page of customers owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customers", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customers") public PageData getCustomers( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -193,8 +193,7 @@ public class CustomerController extends BaseController { @ApiOperation(value = "Get Tenant Customer by Customer title (getTenantCustomer)", notes = "Get the Customer using Customer Title. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/customers", params = {"customerTitle"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/customers") public Customer getTenantCustomer( @Parameter(description = "A string value representing the Customer title.") @RequestParam String customerTitle) throws ThingsboardException { @@ -202,12 +201,10 @@ public class CustomerController extends BaseController { return checkNotNull(customerService.findCustomerByTenantIdAndTitle(tenantId, customerTitle), "Customer with title [" + customerTitle + "] is not found"); } - @ApiOperation(value = "Get customers by Customer Ids (getCustomersByIds)", - notes = "Returns a list of Customer objects based on the provided ids." + - TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @GetMapping(value = "/customers", params = {"customerIds"}) - public List getCustomersByIds( + public List getCustomersByIdsV1( @Parameter(description = "A list of customer ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("customerIds") Set customerUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); @@ -218,4 +215,15 @@ public class CustomerController extends BaseController { return customerService.findCustomersByTenantIdAndIds(tenantId, customerIds); } + @ApiOperation(value = "Get customers by Customer Ids (getCustomersByIds)", + notes = "Returns a list of Customer objects based on the provided ids." + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @GetMapping(value = "/customers/list") + public List getCustomersByIds( + @Parameter(description = "A list of customer ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("customerIds") Set customerUUIDs) throws ThingsboardException { + return getCustomersByIdsV1(customerUUIDs); + } + } 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 b16161437d..9f59044356 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -17,10 +17,10 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.servlet.http.HttpServletResponse; @@ -119,7 +119,7 @@ public class DashboardController extends BaseController { "Used to adjust view of the dashboards according to the difference between browser and server time.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/serverTime") - @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", examples = @ExampleObject(value = "1636023857137"))) + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = "integer", format = "int64", example = "1636023857137"))) public long getServerTime() { return System.currentTimeMillis(); } @@ -131,7 +131,7 @@ public class DashboardController extends BaseController { "The actual value of the limit is configurable in the system configuration file.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/maxDatapointsLimit") - @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", examples = @ExampleObject(value = "5000"))) + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = "integer", format = "int64", example = "5000"))) public long getMaxDatapointsLimit() { return maxDatapointsLimit; } @@ -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) @@ -176,6 +178,8 @@ public class DashboardController extends BaseController { "Referencing non-existing dashboard Id will cause 'Not Found' error. " + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Dashboard entity. " + TENANT_AUTHORITY_PARAGRAPH) + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = Dashboard.class))) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping(value = "/dashboard") public void saveDashboard(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the dashboard.") @@ -325,12 +329,12 @@ public class DashboardController extends BaseController { return tbDashboardService.unassignDashboardFromPublicCustomer(dashboard, getCurrentUser()); } - @ApiOperation(value = "Get Tenant Dashboards by System Administrator (getTenantDashboards)", + @ApiOperation(value = "Get Tenant Dashboards by System Administrator (getTenantDashboardsByTenantId)", notes = "Returns a page of dashboard info objects owned by tenant. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenant/{tenantId}/dashboards", params = {"pageSize", "page"}) - public PageData getTenantDashboards( + @GetMapping(value = "/tenant/{tenantId}/dashboards") + public PageData getTenantDashboardsByTenantId( @Parameter(description = TENANT_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TENANT_ID) String strTenantId, @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @@ -353,7 +357,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects owned by the tenant of a current user. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/tenant/dashboards", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/dashboards") public PageData getTenantDashboards( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -380,7 +384,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects owned by the specified customer. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/dashboards", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/dashboards") public PageData getCustomerDashboards( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -413,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, @@ -574,7 +580,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects assigned to the specified edge. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/edge/{edgeId}/dashboards", params = {"pageSize", "page"}) + @GetMapping(value = "/edge/{edgeId}/dashboards") public PageData getEdgeDashboards( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, @@ -602,13 +608,10 @@ public class DashboardController extends BaseController { return checkNotNull(filteredResult); } - @ApiOperation(value = "Get dashboards by Dashboard Ids (getDashboardsByIds)", - notes = "Returns a list of DashboardInfo objects based on the provided ids. " + - TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboards", params = {"dashboardIds"}) - public List getDashboardsByIds(@Parameter(description = "A list of dashboard ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) - @RequestParam("dashboardIds") Set dashboardUUIDs) throws ThingsboardException { + public List getDashboardsByIdsV1(@RequestParam("dashboardIds") Set dashboardUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List dashboardIds = new ArrayList<>(); for (UUID dashboardUUID : dashboardUUIDs) { @@ -618,6 +621,16 @@ public class DashboardController extends BaseController { return filterDashboardsByReadPermission(dashboards); } + @ApiOperation(value = "Get dashboards by Dashboard Ids (getDashboardsByIds)", + notes = "Returns a list of DashboardInfo objects based on the provided ids. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/dashboards/list") + public List getDashboardsByIds(@Parameter(description = "A list of dashboard ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("dashboardIds") Set dashboardUUIDs) throws ThingsboardException { + return getDashboardsByIdsV1(dashboardUUIDs); + } + private Set customerIdFromStr(String[] strCustomerIds) { Set customerIds = new HashSet<>(); if (strCustomerIds != null) { diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java index 8b899ded41..b7e94d2212 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -75,6 +76,7 @@ public class DeviceConnectivityController extends BaseController { description = "OK", content = @Content( mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = JsonNode.class), examples = { @ExampleObject( name = "http", @@ -105,7 +107,18 @@ public class DeviceConnectivityController extends BaseController { return deviceConnectivityService.findDevicePublishTelemetryCommands(baseUrl, device); } - @ApiOperation(value = "Download server certificate using file path defined in device.connectivity properties (downloadServerCertificate)", notes = "Download server certificate.") + @ApiOperation(value = "Download server certificate using file path defined in device.connectivity properties (downloadServerCertificate)", + notes = "Download server certificate.", + responses = { + @ApiResponse( + responseCode = "200", + description = "OK", + content = @Content( + mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, + schema = @Schema(type = "string", format = "binary") + ) + ) + }) @RequestMapping(value = "/device-connectivity/{protocol}/certificate/download", method = RequestMethod.GET) @ResponseBody public ResponseEntity downloadServerCertificate(@Parameter(description = PROTOCOL_PARAM_DESCRIPTION) @@ -122,7 +135,18 @@ public class DeviceConnectivityController extends BaseController { .body(pemCert); } - @ApiOperation(value = "Download generated docker-compose.yml file for gateway (downloadGatewayDockerCompose)", notes = "Download generated docker-compose.yml for gateway.") + @ApiOperation(value = "Download generated docker-compose.yml file for gateway (downloadGatewayDockerCompose)", + notes = "Download generated docker-compose.yml for gateway.", + responses = { + @ApiResponse( + responseCode = "200", + description = "OK", + content = @Content( + mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, + schema = @Schema(type = "string", format = "binary") + ) + ) + }) @RequestMapping(value = "/device-connectivity/gateway-launch/{deviceId}/docker-compose/download", method = RequestMethod.GET) @ResponseBody public ResponseEntity downloadGatewayDockerCompose(@Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 78b4cc9880..2f43c39f76 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -29,6 +30,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +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; @@ -204,16 +206,16 @@ public class DeviceController extends BaseController { notes = "Create or update the Device. When creating device, platform generates Device Id as " + UUID_WIKI_LINK + "Requires to provide the Device Credentials object as well as an existing device profile ID or use \"default\".\n" + "You may find the example of device with different type of credentials below: \n\n" + - "- Credentials type: \"Access token\" with device profile ID below: \n\n" + + "- Credentials type: **\"Access token\"** with **device profile ID** below: \n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_ACCESS_TOKEN_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"Access token\" with device profile default below: \n\n" + + "- Credentials type: **\"Access token\"** with **device profile default** below: \n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_ACCESS_TOKEN_DEFAULT_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"X509\" with device profile ID below: \n\n" + - "Note: credentialsId - format Sha3Hash, certificateValue - format PEM (with \"--BEGIN CERTIFICATE----\" and -\"----END CERTIFICATE-\").\n\n" + + "- Credentials type: **\"X509\"** with **device profile ID** below: \n\n" + + "Note: **credentialsId** - format **Sha3Hash**, **certificateValue** - format **PEM** (with \"--BEGIN CERTIFICATE----\" and -\"----END CERTIFICATE-\").\n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_X509_CERTIFICATE_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"MQTT_BASIC\" with device profile ID below: \n\n" + + "- Credentials type: **\"MQTT_BASIC\"** with **device profile ID** below: \n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_MQTT_BASIC_DESCRIPTION_MARKDOWN + "\n\n" + - "- You may find the example of LwM2M device and RPK credentials below: \n\n" + + "- You may find the example of **LwM2M** device and **RPK** credentials below: \n\n" + "Note: LwM2M device - only existing device profile ID (Transport configuration -> Transport type: \"LWM2M\".\n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_LVM2M_RPK_DESCRIPTION_MARKDOWN + "\n\n" + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Device entity. " + @@ -320,14 +322,14 @@ public class DeviceController extends BaseController { "Then use current method to update the credentials type and value. It is not possible to create multiple device credentials for the same device.\n" + "The structure of device credentials id and value is simple for the 'ACCESS_TOKEN' but is much more complex for the 'MQTT_BASIC' or 'LWM2M_CREDENTIALS'.\n" + "You may find the example of device with different type of credentials below: \n\n" + - "- Credentials type: \"Access token\" with device ID and with device ID below: \n\n" + + "- Credentials type: **\"Access token\"** with **device ID** and with **device ID** below: \n\n" + DEVICE_UPDATE_CREDENTIALS_PARAM_ACCESS_TOKEN_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"X509\" with device profile ID below: \n\n" + - "Note: credentialsId - format Sha3Hash, certificateValue - format PEM (with \"--BEGIN CERTIFICATE----\" and -\"----END CERTIFICATE-\").\n\n" + + "- Credentials type: **\"X509\"** with **device profile ID** below: \n\n" + + "Note: **credentialsId** - format **Sha3Hash**, **certificateValue** - format **PEM** (with \"--BEGIN CERTIFICATE----\" and -\"----END CERTIFICATE-\").\n\n" + DEVICE_UPDATE_CREDENTIALS_PARAM_X509_CERTIFICATE_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"MQTT_BASIC\" with device profile ID below: \n\n" + + "- Credentials type: **\"MQTT_BASIC\"** with **device profile ID** below: \n\n" + DEVICE_UPDATE_CREDENTIALS_PARAM_MQTT_BASIC_DESCRIPTION_MARKDOWN + "\n\n" + - "- You may find the example of LwM2M device and RPK credentials below: \n\n" + + "- You may find the example of **LwM2M** device and **RPK** credentials below: \n\n" + "Note: LwM2M device - only existing device profile ID (Transport configuration -> Transport type: \"LWM2M\".\n\n" + DEVICE_UPDATE_CREDENTIALS_PARAM_LVM2M_RPK_DESCRIPTION_MARKDOWN + "\n\n" + "Update to real value:\n" + @@ -350,8 +352,7 @@ public class DeviceController extends BaseController { notes = "Returns a page of devices owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/devices", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/devices") public PageData getTenantDevices( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -378,8 +379,7 @@ public class DeviceController extends BaseController { notes = "Returns a page of devices info objects owned by tenant. " + PAGE_DATA_PARAMETERS + DEVICE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/deviceInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/deviceInfos") public PageData getTenantDeviceInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -411,25 +411,31 @@ public class DeviceController extends BaseController { return checkNotNull(deviceService.findDeviceInfosByFilter(filter.build(), pageLink)); } - @ApiOperation(value = "Get Tenant Device (getTenantDevice)", - notes = "Requested device must be owned by tenant that the user belongs to. " + - "Device name is an unique property of device. So it can be used to identify the device." + TENANT_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/devices", params = {"deviceName"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/devices", params = {"deviceName"}) public Device getTenantDevice( - @Parameter(description = DEVICE_NAME_DESCRIPTION) @RequestParam String deviceName) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); return checkNotNull(deviceService.findDeviceByTenantIdAndName(tenantId, deviceName)); } + @ApiOperation(value = "Get Tenant Device (getTenantDeviceByName)", + notes = "Requested device must be owned by tenant that the user belongs to. " + + "Device name is an unique property of device. So it can be used to identify the device." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/tenant/device") + public Device getTenantDeviceByName( + @Parameter(description = DEVICE_NAME_DESCRIPTION) + @RequestParam String deviceName) throws ThingsboardException { + return getTenantDevice(deviceName); + } + @ApiOperation(value = "Get Customer Devices (getCustomerDevices)", notes = "Returns a page of devices objects assigned to customer. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/devices", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/devices") public PageData getCustomerDevices( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -461,8 +467,7 @@ public class DeviceController extends BaseController { notes = "Returns a page of devices info objects assigned to customer. " + PAGE_DATA_PARAMETERS + DEVICE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/deviceInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/deviceInfos") public PageData getCustomerDeviceInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable("customerId") String strCustomerId, @@ -502,8 +507,7 @@ public class DeviceController extends BaseController { @ApiOperation(value = "Get Devices By Ids (getDevicesByIds)", notes = "Requested devices must be owned by tenant or assigned to customer which user is performing the request. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/devices", params = {"deviceIds"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/devices") public List getDevicesByIds( @Parameter(description = "A list of devices ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) @RequestParam("deviceIds") String[] strDeviceIds) throws ThingsboardException, ExecutionException, InterruptedException { @@ -524,14 +528,14 @@ public class DeviceController extends BaseController { return checkNotNull(devices.get()); } - @ApiOperation(value = "Find related devices (findByQuery)", + @ApiOperation(value = "Find related devices (findDevicesByQuery)", notes = "Returns all devices that are related to the specific entity. " + "The entity id, relation type, device types, depth of the search, and other query parameters defined using complex 'DeviceSearchQuery' object. " + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/devices", method = RequestMethod.POST) @ResponseBody - public List findByQuery( + public List findDevicesByQuery( @Parameter(description = "The device search query JSON") @RequestBody DeviceSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query); @@ -730,8 +734,7 @@ public class DeviceController extends BaseController { notes = "Returns a page of devices assigned to edge. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edge/{edgeId}/devices", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/{edgeId}/devices") public PageData getEdgeDevices( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java index 343703cb7d..f70583f721 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -129,7 +130,7 @@ public class DeviceProfileController extends BaseController { return checkNotNull(deviceProfileService.findDefaultDeviceProfileInfo(getTenantId())); } - @ApiOperation(value = "Get time series keys (getTimeseriesKeys)", + @ApiOperation(value = "Get time series keys (getDeviceProfileTimeseriesKeys)", notes = "Get a set of unique time series keys used by devices that belong to specified profile. " + "If profile is not set returns a list of unique keys among all profiles. " + "The call is used for auto-complete in the UI forms. " + @@ -138,7 +139,7 @@ public class DeviceProfileController extends BaseController { @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @RequestMapping(value = "/deviceProfile/devices/keys/timeseries", method = RequestMethod.GET) @ResponseBody - public List getTimeseriesKeys( + public List getDeviceProfileTimeseriesKeys( @Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) @RequestParam(name = DEVICE_PROFILE_ID, required = false) String deviceProfileIdStr) throws ThingsboardException { DeviceProfileId deviceProfileId; @@ -228,8 +229,7 @@ public class DeviceProfileController extends BaseController { notes = "Returns a page of devices profile objects owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/deviceProfiles", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/deviceProfiles") public PageData getDeviceProfiles( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -249,8 +249,7 @@ public class DeviceProfileController extends BaseController { notes = "Returns a page of devices profile info objects owned by tenant. " + PAGE_DATA_PARAMETERS + DEVICE_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/deviceProfileInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/deviceProfileInfos") public PageData getDeviceProfileInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -282,14 +281,10 @@ public class DeviceProfileController extends BaseController { return checkNotNull(deviceProfileService.findDeviceProfileNamesByTenantId(tenantId, activeOnly)); } - @ApiOperation(value = "Get Device Profile Infos By Ids (getDeviceProfilesByIds)", - notes = "Requested device profiles must be owned by tenant which is performing the request. " + - NEW_LINE) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/deviceProfileInfos", params = {"deviceProfileIds"}) - public List getDeviceProfileInfosByIds( - @Parameter(description = "A list of device profile ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) - @RequestParam("deviceProfileIds") Set deviceProfileUUIDs) throws ThingsboardException { + public List getDeviceProfileInfosByIdsV1(@RequestParam("deviceProfileIds") Set deviceProfileUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List deviceProfileIds = new ArrayList<>(); for (UUID deviceProfileUUID : deviceProfileUUIDs) { @@ -298,4 +293,15 @@ public class DeviceProfileController extends BaseController { return deviceProfileService.findDeviceProfilesByIds(tenantId, deviceProfileIds); } + @ApiOperation(value = "Get Device Profile Infos By Ids (getDeviceProfileInfosByIds)", + notes = "Requested device profiles must be owned by tenant which is performing the request. " + + NEW_LINE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/deviceProfileInfos/list") + public List getDeviceProfileInfosByIds( + @Parameter(description = "A list of device profile ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("deviceProfileIds") Set deviceProfileUUIDs) throws ThingsboardException { + return getDeviceProfileInfosByIdsV1(deviceProfileUUIDs); + } + } 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 0afffda159..c34bbe6ac9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DomainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DomainController.java @@ -81,31 +81,31 @@ public class DomainController extends BaseController { return tbDomainService.save(domain, getOAuth2ClientIds(ids), getCurrentUser()); } - @ApiOperation(value = "Update oauth2 clients (updateOauth2Clients)", + @ApiOperation(value = "Update oauth2 clients (updateDomainOauth2Clients)", notes = "Update oauth2 clients for the specified domain. ") @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") @PutMapping(value = "/domain/{id}/oauth2Clients") - public void updateOauth2Clients(@PathVariable UUID id, - @RequestBody UUID[] clientIds) throws ThingsboardException { + public void updateDomainOauth2Clients(@PathVariable UUID id, + @RequestBody UUID[] clientIds) throws ThingsboardException { DomainId domainId = new DomainId(id); Domain domain = checkDomainId(domainId, Operation.WRITE); List oAuth2ClientIds = getOAuth2ClientIds(clientIds); 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/EdgeController.java b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java index f0d9e39d6a..361104d9a2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EdgeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -277,7 +278,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges info objects owned by tenant. " + PAGE_DATA_PARAMETERS + EDGE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/tenant/edgeInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/edgeInfos") public PageData getTenantEdgeInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -300,17 +301,24 @@ public class EdgeController extends BaseController { } } - @ApiOperation(value = "Get Tenant Edge (getTenantEdge)", - notes = "Requested edge must be owned by tenant or customer that the user belongs to. " + - "Edge name is an unique property of edge. So it can be used to identify the edge." + TENANT_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/tenant/edges", params = {"edgeName"}) - public Edge getTenantEdge(@Parameter(description = "Unique name of the edge", required = true) - @RequestParam String edgeName) throws ThingsboardException { + public Edge getTenantEdge(@RequestParam String edgeName) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); return checkNotNull(edgeService.findEdgeByTenantIdAndName(tenantId, edgeName)); } + @ApiOperation(value = "Get Tenant Edge by name (getTenantEdgeByName)", + notes = "Requested edge must be owned by tenant or customer that the user belongs to. " + + "Edge name is an unique property of edge. So it can be used to identify the edge." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/tenant/edge") + public Edge getTenantEdgeByName(@Parameter(description = "Unique name of the edge", required = true) + @RequestParam String edgeName) throws ThingsboardException { + return getTenantEdge(edgeName); + } + @ApiOperation(value = "Set root rule chain for provided edge (setEdgeRootRuleChain)", notes = "Change root rule chain of the edge to the new provided rule chain. \n" + "This operation will send a notification to update root rule chain on remote edge service." + TENANT_AUTHORITY_PARAGRAPH) @@ -334,7 +342,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges objects assigned to customer. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/edges", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/edges") public PageData getCustomerEdges( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -369,7 +377,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges info objects assigned to customer. " + PAGE_DATA_PARAMETERS + EDGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/edgeInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/edgeInfos") public PageData getCustomerEdgeInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -400,12 +408,10 @@ public class EdgeController extends BaseController { return checkNotNull(result); } - @ApiOperation(value = "Get Edges By Ids (getEdgesByIds)", - notes = "Requested edges must be owned by tenant or assigned to customer which user is performing the request." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/edges", params = {"edgeIds"}) public List getEdgesByIds( - @Parameter(description = "A list of edges ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("edgeIds") String[] strEdgeIds) throws ThingsboardException, ExecutionException, InterruptedException { checkArrayParameter("edgeIds", strEdgeIds); SecurityUser user = getCurrentUser(); @@ -425,13 +431,23 @@ public class EdgeController extends BaseController { return checkNotNull(edges); } - @ApiOperation(value = "Find related edges (findByQuery)", + @ApiOperation(value = "Get Edges By Ids (getEdgeList)", + notes = "Requested edges must be owned by tenant or assigned to customer which user is performing the request." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/edges/list") + public List getEdgeList( + @Parameter(description = "A list of edges ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("edgeIds") String[] strEdgeIds) throws ThingsboardException, ExecutionException, InterruptedException { + return getEdgesByIds(strEdgeIds); + } + + @ApiOperation(value = "Find related edges (findEdgesByQuery)", notes = "Returns all edges that are related to the specific entity. " + "The entity id, relation type, edge types, depth of the search, and other query parameters defined using complex 'EdgeSearchQuery' object. " + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping(value = "/edges") - public List findByQuery(@RequestBody EdgeSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { + public List findEdgesByQuery(@RequestBody EdgeSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query); checkNotNull(query.getParameters()); checkNotNull(query.getEdgeTypes()); diff --git a/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java b/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java index 10f4cd2f51..8dd607bb6f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; 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.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -213,19 +214,19 @@ public class EntitiesVersionControlController extends BaseController { " \"timestamp\": 1655198593000,\n" + " \"id\": \"fd82625bdd7d6131cf8027b44ee967012ecaf990\",\n" + " \"name\": \"Devices and assets - v2.0\",\n" + - " \"author\": \"John Doe \"\n" + + " \"author\": \"John Doe (johndoe@gmail.com)\"\n" + " },\n" + " {\n" + " \"timestamp\": 1655198528000,\n" + " \"id\": \"682adcffa9c8a2f863af6f00c4850323acbd4219\",\n" + " \"name\": \"Update my device\",\n" + - " \"author\": \"John Doe \"\n" + + " \"author\": \"John Doe (johndoe@gmail.com)\"\n" + " },\n" + " {\n" + " \"timestamp\": 1655198280000,\n" + " \"id\": \"d2a6087c2b30e18cc55e7cdda345a8d0dfb959a4\",\n" + " \"name\": \"Devices and assets - v1.0\",\n" + - " \"author\": \"John Doe \"\n" + + " \"author\": \"John Doe (johndoe@gmail.com)\"\n" + " }\n" + " ],\n" + " \"totalPages\": 1,\n" + @@ -234,7 +235,7 @@ public class EntitiesVersionControlController extends BaseController { "}" + MARKDOWN_CODE_BLOCK_END + TENANT_AUTHORITY_PARAGRAPH) - @GetMapping(value = "/version/{entityType}/{externalEntityUuid}", params = {"branch", "pageSize", "page"}) + @GetMapping(value = "/version/{entityType}/{externalEntityUuid}") public DeferredResult> listEntityVersions(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable EntityType entityType, @Parameter(description = "A string value representing external entity id. This is `externalId` property of an entity, or otherwise if not set - simply id of this entity.") @@ -263,7 +264,7 @@ public class EntitiesVersionControlController extends BaseController { "If specified branch does not exist - empty page data will be returned. " + "The response structure is the same as for `listEntityVersions` API method." + TENANT_AUTHORITY_PARAGRAPH) - @GetMapping(value = "/version/{entityType}", params = {"branch", "pageSize", "page"}) + @GetMapping(value = "/version/{entityType}") public DeferredResult> listEntityTypeVersions(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable EntityType entityType, @Parameter(description = BRANCH_PARAM_DESCRIPTION, required = true) @@ -288,7 +289,7 @@ public class EntitiesVersionControlController extends BaseController { "If specified branch does not exist - empty page data will be returned. " + "The response format is the same as for `listEntityVersions` API method." + TENANT_AUTHORITY_PARAGRAPH) - @GetMapping(value = "/version", params = {"branch", "pageSize", "page"}) + @GetMapping(value = "/version") public DeferredResult> listVersions(@Parameter(description = BRANCH_PARAM_DESCRIPTION, required = true) @RequestParam String branch, @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @@ -355,7 +356,7 @@ public class EntitiesVersionControlController extends BaseController { "Returns an object with current entity data and the one at a specific version. " + "Entity data structure is the same as stored in a repository. " + TENANT_AUTHORITY_PARAGRAPH) - @GetMapping(value = "/diff/{entityType}/{internalEntityUuid}", params = {"versionId"}) + @GetMapping(value = "/diff/{entityType}/{internalEntityUuid}") public DeferredResult compareEntityDataToVersion(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable EntityType entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java index bb025a5fcf..cde3129d53 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AvailableEntityKeys; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; @@ -47,6 +48,8 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.query.EntityQueryService; import org.thingsboard.server.service.security.permission.Operation; +import java.util.Set; + import static org.thingsboard.server.controller.ControllerConstants.ALARM_DATA_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_COUNT_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_DATA_QUERY_DESCRIPTION; @@ -115,9 +118,11 @@ public class EntityQueryController extends BaseController { return entityQueryService.countAlarmsByQuery(getCurrentUser(), query); } + @Deprecated(forRemoval = true) @ApiOperation( - value = "Find Available Entity Keys by Query", + value = "Find Available Entity Keys by Query (deprecated)", notes = """ + **Deprecated.** Use the V2 endpoint (`POST /api/v2/entitiesQuery/find/keys`) instead.\n Returns unique time series and/or attribute key names from entities matching the query.\n Executes the Entity Data Query to find up to 100 entities, then fetches and aggregates all distinct key names.\n Primarily used for UI features like autocomplete suggestions.""" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH @@ -128,9 +133,6 @@ public class EntityQueryController extends BaseController { @Parameter(description = "Entity data query to find entities. Page size is capped at 100.") @RequestBody EntityDataQuery query, - // fixme: combination of timeseries = false and attributes = false is allowed, but always results in empty response, therefore does not make any sense - // such combinations should NOT be allowed, but changing this will break clients - @Parameter(description = """ When true, includes unique time series key names in the response. When false, the 'timeseries' list will be empty.""") @@ -155,6 +157,54 @@ public class EntityQueryController extends BaseController { return wrapFuture(entityQueryService.getKeysByQuery(getCurrentUser(), getTenantId(), query, includeTimeseries, includeAttributes, scope)); } + @ApiOperation( + value = "Find Available Entity Keys By Query", + notes = """ + Discovers unique time series and/or attribute key names available on entities that match the given query. + Works in two steps: first, the request body (an Entity Data Query) is executed to find matching entities + (page size is capped at 100); then, all distinct key names are collected from those entities.\n + Optionally, each key can include a sample — the most recent value (by timestamp) for that key + across all matched entities.""" + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @PostMapping("/v2/entitiesQuery/find/keys") + public DeferredResult findAvailableEntityKeysByQueryV2( + @Parameter(description = "Entity data query to find entities. Page size is capped at 100.") + @RequestBody EntityDataQuery query, + + @Parameter(description = """ + When true, includes unique time series keys in the response. + When false, the 'timeseries' field is omitted. At least one of 'includeTimeseries' or 'includeAttributes' must be true.""") + @RequestParam(defaultValue = "true") boolean includeTimeseries, + + @Parameter(description = """ + When true, includes unique attribute keys in the response. + When false, the 'attributes' field is omitted. At least one of 'includeTimeseries' or 'includeAttributes' must be true.""") + @RequestParam(defaultValue = "true") boolean includeAttributes, + + @Parameter(description = """ + Filters attribute keys by scope. Only applies when 'includeAttributes' is true. + When not specified, scopes are auto-determined: all three scopes (server, client, shared) for device entities, + server scope only for other entity types.""", + schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"})) + @RequestParam(required = false) Set scopes, + + @Parameter(description = """ + When true, each key entry includes a 'sample' object with the most recent value and timestamp. + When false, only key names are returned (sample is omitted from JSON).""") + @RequestParam(defaultValue = "false") boolean includeSamples + ) throws ThingsboardException { + resolveQuery(query); + EntityDataPageLink pageLink = query.getPageLink(); + if (pageLink.getPageSize() > MAX_PAGE_SIZE) { + pageLink.setPageSize(MAX_PAGE_SIZE); + } + return wrapFuture(entityQueryService.findAvailableEntityKeysByQuery( + getCurrentUser(), query, + includeTimeseries, includeAttributes, scopes, includeSamples)); + } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") @PostMapping("/edqs/system/request") public void processSystemEdqsRequest(@RequestBody ToCoreEdqsRequest request) { diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java b/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java index 9314f584ab..a5c82f11f4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import lombok.RequiredArgsConstructor; 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; @@ -71,25 +73,22 @@ public class EntityRelationController extends BaseController { "If the user has the authority of 'Tenant Administrator', the server checks that the entity is owned by the same tenant. " + "If the user has the authority of 'Customer User', the server checks that the entity is assigned to the same customer."; - @ApiOperation(value = "Create Relation (saveRelation)", - notes = "Creates or updates a relation between two entities in the platform. " + - "Relations unique key is a combination of from/to entity id and relation type group and relation type. " + - SECURITY_CHECKS_ENTITIES_DESCRIPTION) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @PostMapping("/relation") - public void saveRelation(@Parameter(description = "A JSON value representing the relation.", required = true) + @PostMapping(value = "/relation") + public void saveRelationV1(@Parameter(description = "A JSON value representing the relation.", required = true) @RequestBody EntityRelation relation) throws ThingsboardException { doSave(relation); } - @ApiOperation(value = "Create Relation (saveRelationV2)", + @ApiOperation(value = "Create Relation (saveRelation)", notes = "Creates or updates a relation between two entities in the platform. " + "Relations unique key is a combination of from/to entity id and relation type group and relation type. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @PostMapping("/v2/relation") - public EntityRelation saveRelationV2(@Parameter(description = "A JSON value representing the relation.", required = true) - @RequestBody EntityRelation relation) throws ThingsboardException { + @PostMapping(value = "/v2/relation") + public EntityRelation saveRelation(@Parameter(description = "A JSON value representing the relation.", required = true) + @RequestBody EntityRelation relation) throws ThingsboardException { return doSave(relation); } @@ -103,11 +102,10 @@ public class EntityRelationController extends BaseController { return tbEntityRelationService.save(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); } - @ApiOperation(value = "Delete Relation (deleteRelation)", - notes = "Deletes a relation between two entities in the platform. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @DeleteMapping(value = "/relation", params = {FROM_ID, FROM_TYPE, RELATION_TYPE, TO_ID, TO_TYPE}) - public void deleteRelation(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @DeleteMapping(value = "/relation") + public void deleteRelationV1(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, @@ -116,16 +114,16 @@ public class EntityRelationController extends BaseController { doDelete(strFromId, strFromType, strRelationType, strRelationTypeGroup, strToId, strToType); } - @ApiOperation(value = "Delete Relation (deleteRelationV2)", + @ApiOperation(value = "Delete Relation (deleteRelation)", notes = "Deletes a relation between two entities in the platform. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @DeleteMapping(value = "/v2/relation", params = {FROM_ID, FROM_TYPE, RELATION_TYPE, TO_ID, TO_TYPE}) - public EntityRelation deleteRelationV2(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, - @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, - @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, - @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType) throws ThingsboardException { + @DeleteMapping(value = "/v2/relation") + public EntityRelation deleteRelation(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, + @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType) throws ThingsboardException { return doDelete(strFromId, strFromType, strRelationType, strRelationTypeGroup, strToId, strToType); } @@ -144,11 +142,11 @@ public class EntityRelationController extends BaseController { return tbEntityRelationService.delete(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); } - @ApiOperation(value = "Delete common relations (deleteCommonRelations)", + @ApiOperation(value = "Delete common relations (deleteRelations)", notes = "Deletes all the relations ('from' and 'to' direction) for the specified entity and relation type group: 'COMMON'. " + SECURITY_CHECKS_ENTITY_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") - @DeleteMapping(value = "/relations", params = {"entityId", "entityType"}) + @DeleteMapping(value = "/relations") public void deleteRelations(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam("entityId") String strId, @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam("entityType") String strType) throws ThingsboardException { checkParameter("entityId", strId); @@ -161,7 +159,7 @@ public class EntityRelationController extends BaseController { @ApiOperation(value = "Get Relation (getRelation)", notes = "Returns relation object between two specified entities if present. Otherwise throws exception. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/relation", params = {FROM_ID, FROM_TYPE, RELATION_TYPE, TO_ID, TO_TYPE}) + @GetMapping(value = "/relation") public EntityRelation getRelation(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, @@ -180,14 +178,11 @@ public class EntityRelationController extends BaseController { return checkNotNull(relationService.getRelation(getTenantId(), fromId, toId, strRelationType, typeGroup)); } - @ApiOperation(value = "Get List of Relations (findByFrom)", - notes = "Returns list of relation objects for the specified entity by the 'from' direction. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations", params = {FROM_ID, FROM_TYPE}) - public List findByFrom(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, - @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + public List findByFrom(@RequestParam(FROM_ID) String strFromId, + @RequestParam(FROM_TYPE) String strFromType, @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { checkParameter(FROM_ID, strFromId); checkParameter(FROM_TYPE, strFromType); @@ -197,9 +192,19 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findByFrom(getTenantId(), entityId, typeGroup))); } - @ApiOperation(value = "Get List of Relation Infos (findInfoByFrom)", - notes = "Returns list of relation info objects for the specified entity by the 'from' direction. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION) + @ApiOperation(value = "Get List of Relations (findEntityRelationsByFrom)", + notes = "Returns list of relation objects for the specified entity by the 'from' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/from/{fromType}/{fromId}") + public List findEntityRelationsByFrom(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_TYPE) String strFromType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_ID) String strFromId, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + return findByFrom(strFromId, strFromType, strRelationTypeGroup); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations/info", params = {FROM_ID, FROM_TYPE}) public List findInfoByFrom(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, @@ -214,15 +219,24 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findInfoByFrom(getTenantId(), entityId, typeGroup).get())); } - @ApiOperation(value = "Get List of Relations (findByFrom)", - notes = "Returns list of relation objects for the specified entity by the 'from' direction and relation type. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION) + @ApiOperation(value = "Get List of Relation Infos (findEntityRelationInfosByFrom)", + notes = "Returns list of relation info objects for the specified entity by the 'from' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/info/from/{fromType}/{fromId}") + public List findEntityRelationInfosByFrom(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_TYPE) String strFromType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_ID) String strFromId, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException, ExecutionException, InterruptedException { + return findInfoByFrom(strFromId, strFromType, strRelationTypeGroup); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations", params = {FROM_ID, FROM_TYPE, RELATION_TYPE}) - public List findByFrom(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, - @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, - @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + public List findByFrom(@RequestParam(FROM_ID) String strFromId, + @RequestParam(FROM_TYPE) String strFromType, + @RequestParam(RELATION_TYPE) String strRelationType, @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { checkParameter(FROM_ID, strFromId); checkParameter(FROM_TYPE, strFromType); @@ -233,10 +247,21 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findByFromAndType(getTenantId(), entityId, strRelationType, typeGroup))); } - @ApiOperation(value = "Get List of Relations (findByTo)", - notes = "Returns list of relation objects for the specified entity by the 'to' direction. " + + @ApiOperation(value = "Get List of Relations (findEntityRelationsByFromAndRelationType)", + notes = "Returns list of relation objects for the specified entity by the 'from' direction and relation type. " + SECURITY_CHECKS_ENTITY_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/from/{fromType}/{fromId}/{relationType}") + public List findEntityRelationsByFromAndRelationType(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_TYPE) String strFromType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_ID) String strFromId, + @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(RELATION_TYPE) String strRelationType, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + return findByFrom(strFromId, strFromType, strRelationType, strRelationTypeGroup); + } + + @Hidden + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations", params = {TO_ID, TO_TYPE}) public List findByTo(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType, @@ -250,9 +275,19 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findByTo(getTenantId(), entityId, typeGroup))); } - @ApiOperation(value = "Get List of Relation Infos (findInfoByTo)", - notes = "Returns list of relation info objects for the specified entity by the 'to' direction. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION) + @ApiOperation(value = "Get List of Relations (findEntityRelationsByTo)", + notes = "Returns list of relation objects for the specified entity by the 'to' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/to/{toType}/{toId}") + public List findEntityRelationsByTo(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(TO_TYPE) String strToType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TO_ID) String strToId, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + return findByTo(strToId, strToType, strRelationTypeGroup); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations/info", params = {TO_ID, TO_TYPE}) public List findInfoByTo(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, @@ -267,9 +302,19 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findInfoByTo(getTenantId(), entityId, typeGroup).get())); } - @ApiOperation(value = "Get List of Relations (findByTo)", - notes = "Returns list of relation objects for the specified entity by the 'to' direction and relation type. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION) + @ApiOperation(value = "Get List of Relation Infos (findEntityRelationInfosByTo)", + notes = "Returns list of relation info objects for the specified entity by the 'to' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/info/to/{toType}/{toId}") + public List findEntityRelationInfosByTo(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(TO_TYPE) String strToType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TO_ID) String strToId, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException, ExecutionException, InterruptedException { + return findInfoByTo(strToId, strToType, strRelationTypeGroup); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations", params = {TO_ID, TO_TYPE, RELATION_TYPE}) public List findByTo(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, @@ -286,28 +331,41 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findByToAndType(getTenantId(), entityId, strRelationType, typeGroup))); } - @ApiOperation(value = "Find related entities (findByQuery)", + @ApiOperation(value = "Get List of Relations (findEntityRelationsByToAndRelationType)", + notes = "Returns list of relation objects for the specified entity by the 'to' direction and relation type. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/to/{toType}/{toId}/{relationType}") + public List findEntityRelationsByToAndRelationType(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(TO_TYPE) String strToType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TO_ID) String strToId, + @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(RELATION_TYPE) String strRelationType, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + return findByTo(strToId, strToType, strRelationType, strRelationTypeGroup); + } + + @ApiOperation(value = "Find related entities (findEntityRelationsByQuery)", notes = "Returns all entities that are related to the specific entity. " + "The entity id, relation type, entity types, depth of the search, and other query parameters defined using complex 'EntityRelationsQuery' object. " + "See 'Model' tab of the Parameters for more info.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping("/relations") - public List findByQuery(@Parameter(description = "A JSON value representing the entity relations query object.", required = true) - @RequestBody EntityRelationsQuery query) throws ThingsboardException, ExecutionException, InterruptedException { + public List findEntityRelationsByQuery(@Parameter(description = "A JSON value representing the entity relations query object.", required = true) + @RequestBody EntityRelationsQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query.getParameters()); checkNotNull(query.getFilters()); checkEntityId(query.getParameters().getEntityId(), Operation.READ); return checkNotNull(filterRelationsByReadPermission(relationService.findByQuery(getTenantId(), query).get())); } - @ApiOperation(value = "Find related entity infos (findInfoByQuery)", + @ApiOperation(value = "Find related entity infos (findEntityRelationInfosByQuery)", notes = "Returns all entity infos that are related to the specific entity. " + "The entity id, relation type, entity types, depth of the search, and other query parameters defined using complex 'EntityRelationsQuery' object. " + "See 'Model' tab of the Parameters for more info. " + RELATION_INFO_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping("/relations/info") - public List findInfoByQuery(@Parameter(description = "A JSON value representing the entity relations query object.", required = true) - @RequestBody EntityRelationsQuery query) throws ThingsboardException, ExecutionException, InterruptedException { + public List findEntityRelationInfosByQuery(@Parameter(description = "A JSON value representing the entity relations query object.", required = true) + @RequestBody EntityRelationsQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query.getParameters()); checkNotNull(query.getFilters()); checkEntityId(query.getParameters().getEntityId(), Operation.READ); diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index e061c691ac..821a3c77a1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -23,7 +24,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -79,7 +79,6 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE; import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -87,6 +86,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D 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.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; @@ -167,17 +167,25 @@ public class EntityViewController extends BaseController { tbEntityViewService.delete(entityView, getCurrentUser()); } - @ApiOperation(value = "Get Entity View by name (getTenantEntityView)", - notes = "Fetch the Entity View object based on the tenant id and entity view name. " + TENANT_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/tenant/entityViews", params = {"entityViewName"}) public EntityView getTenantEntityView( - @Parameter(description = "Entity View name") @RequestParam String entityViewName) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); return checkNotNull(entityViewService.findEntityViewByTenantIdAndName(tenantId, entityViewName)); } + @ApiOperation(value = "Get Entity View by name (getTenantEntityViewByName)", + notes = "Fetch the Entity View object based on the tenant id and entity view name. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/tenant/entityView") + public EntityView getTenantEntityViewByName( + @Parameter(description = "Entity View name") + @RequestParam String entityViewName) throws ThingsboardException { + return getTenantEntityView(entityViewName); + } + @ApiOperation(value = "Assign Entity View to customer (assignEntityViewToCustomer)", notes = "Creates assignment of the Entity View to customer. Customer will be able to query Entity View afterwards." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @@ -222,7 +230,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of Entity View objects assigned to customer. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/entityViews", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/entityViews") public PageData getCustomerEntityViews( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -254,7 +262,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of Entity View info objects assigned to customer. " + ENTITY_VIEW_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/entityViewInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/entityViewInfos") public PageData getCustomerEntityViewInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -286,7 +294,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of entity views owned by tenant. " + ENTITY_VIEW_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/tenant/entityViews", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/entityViews") public PageData getTenantEntityViews( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -314,7 +322,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of entity views info owned by tenant. " + ENTITY_VIEW_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/tenant/entityViewInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/entityViewInfos") public PageData getTenantEntityViewInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -337,13 +345,13 @@ public class EntityViewController extends BaseController { } } - @ApiOperation(value = "Find related entity views (findByQuery)", + @ApiOperation(value = "Find related entity views (findEntityViewsByQuery)", notes = "Returns all entity views that are related to the specific entity. " + "The entity id, relation type, entity view types, depth of the search, and other query parameters defined using complex 'EntityViewSearchQuery' object. " + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping(value = "/entityViews") - public List findByQuery( + public List findEntityViewsByQuery( @Parameter(description = "The entity view search query JSON") @RequestBody EntityViewSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query); @@ -429,7 +437,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/edge/{edgeId}/entityViews", params = {"pageSize", "page"}) + @GetMapping(value = "/edge/{edgeId}/entityViews") public PageData getEdgeEntityViews( @PathVariable(EDGE_ID) String strEdgeId, @RequestParam int pageSize, @@ -459,11 +467,10 @@ public class EntityViewController extends BaseController { return checkNotNull(filteredResult); } - @ApiOperation(value = "Get Entity Views By Ids (getEntityViewsByIds)", - notes = "Requested entity views must be owned by tenant or assigned to customer which user is performing the request. ") + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/entityViews", params = {"entityViewIds"}) - public List getEntityViewsByIds(@Parameter(description = "A list of entity view ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + public List getEntityViewsByIdsV1(@Parameter(description = "A list of entity view ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("entityViewIds") Set entityViewUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List entityViewIds = new ArrayList<>(); @@ -474,6 +481,15 @@ public class EntityViewController extends BaseController { return filterEntityViewsByReadPermission(entityViews); } + @ApiOperation(value = "Get Entity Views By Ids (getEntityViewsByIds)", + notes = "Requested entity views must be owned by tenant or assigned to customer which user is performing the request. ") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/entityViews/list") + public List getEntityViewsByIds(@Parameter(description = "A list of entity view ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("entityViewIds") Set entityViewUUIDs) throws ThingsboardException { + return getEntityViewsByIdsV1(entityViewUUIDs); + } + private List filterEntityViewsByReadPermission(List entityViews) { return entityViews.stream().filter(entityView -> { try { diff --git a/application/src/main/java/org/thingsboard/server/controller/EventController.java b/application/src/main/java/org/thingsboard/server/controller/EventController.java index 7bb2d33366..6e8c17c674 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EventController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EventController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.beans.factory.annotation.Autowired; @@ -113,13 +114,13 @@ public class EventController extends BaseController { @Autowired private EventService eventService; - @ApiOperation(value = "Get Events by type (getEvents)", + @ApiOperation(value = "Get Events by type (getEventsByType)", notes = "Returns a page of events for specified entity by specifying event type. " + PAGE_DATA_PARAMETERS) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/events/{entityType}/{entityId}/{eventType}", method = RequestMethod.GET) @ResponseBody - public PageData getEvents( + public PageData getEventsByType( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(ENTITY_TYPE) String strEntityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @@ -152,16 +153,12 @@ public class EventController extends BaseController { return checkNotNull(eventService.findEvents(tenantId, entityId, resolveEventType(eventType), pageLink)); } - @ApiOperation(value = "Get Events (Deprecated)", - notes = "Returns a page of events for specified entity. Deprecated and will be removed in next minor release. " + - "The call was deprecated to improve the performance of the system. " + - "Current implementation will return 'Lifecycle' events only. " + - "Use 'Get events by type' or 'Get events by filter' instead. " + - PAGE_DATA_PARAMETERS) + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/events/{entityType}/{entityId}", method = RequestMethod.GET) @ResponseBody - public PageData getEvents( + public PageData getEventsDeprecated( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(ENTITY_TYPE) String strEntityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @@ -194,14 +191,14 @@ public class EventController extends BaseController { return checkNotNull(eventService.findEvents(tenantId, entityId, EventType.LC_EVENT, pageLink)); } - @ApiOperation(value = "Get Events by event filter (getEvents)", + @ApiOperation(value = "Get Events by event filter (getEventsByFilter)", notes = "Returns a page of events for the chosen entity by specifying the event filter. " + PAGE_DATA_PARAMETERS + NEW_LINE + EVENT_FILTER_DEFINITION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/events/{entityType}/{entityId}", method = RequestMethod.POST) @ResponseBody - public PageData getEvents( + public PageData getEventsByFilter( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(ENTITY_TYPE) String strEntityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index 5e3f3be442..5566c11a4a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -69,11 +69,11 @@ public class Lwm2mController extends BaseController { return lwM2MService.getServerSecurityInfo(bootstrapServer); } - @ApiOperation(hidden = true, value = "Save device with credentials (Deprecated)") + @ApiOperation(hidden = true, value = "Save LwM2M device with credentials (saveLwm2mDeviceWithCredentials) (Deprecated)") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/lwm2m/device-credentials", method = RequestMethod.POST) @ResponseBody - public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { + public Device saveLwm2mDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator(), DEFAULT.uniquifyStrategy()); diff --git a/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java b/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java index 70ac96db67..f8a3f8ad0b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java @@ -43,12 +43,12 @@ public class MailConfigTemplateController extends BaseController { private static final String MAIL_CONFIG_TEMPLATE_DEFINITION = "Mail configuration template is set of default smtp settings for mail server that specific provider supports"; private final TbMailConfigTemplateService mailConfigTemplateService; - @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getMailConfigTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, notes = MAIL_CONFIG_TEMPLATE_DEFINITION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(method = RequestMethod.GET, produces = "application/json") @ResponseBody - public JsonNode getClientRegistrationTemplates() throws ThingsboardException, IOException { + public JsonNode getMailConfigTemplates() throws ThingsboardException, IOException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return mailConfigTemplateService.findAllMailConfigTemplates(); } diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileAppBundleController.java b/application/src/main/java/org/thingsboard/server/controller/MobileAppBundleController.java index 304805a820..feb4aed0e3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileAppBundleController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileAppBundleController.java @@ -81,12 +81,12 @@ public class MobileAppBundleController extends BaseController { return tbMobileAppBundleService.save(mobileAppBundle, getOAuth2ClientIds(ids), getCurrentUser()); } - @ApiOperation(value = "Update oauth2 clients (updateOauth2Clients)", + @ApiOperation(value = "Update oauth2 clients (updateMobileAppBundleOauth2Clients)", notes = "Update oauth2 clients of the specified mobile app bundle." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PutMapping(value = "/mobile/bundle/{id}/oauth2Clients") - public void updateOauth2Clients(@PathVariable UUID id, - @RequestBody UUID[] clientIds) throws ThingsboardException { + public void updateMobileAppBundleOauth2Clients(@PathVariable UUID id, + @RequestBody UUID[] clientIds) throws ThingsboardException { MobileAppBundleId mobileAppBundleId = new MobileAppBundleId(id); MobileAppBundle mobileAppBundle = checkMobileAppBundleId(mobileAppBundleId, Operation.WRITE); List oAuth2ClientIds = getOAuth2ClientIds(clientIds); diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileAppController.java b/application/src/main/java/org/thingsboard/server/controller/MobileAppController.java index 7c77a26e42..3ca23b2ddc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileAppController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileAppController.java @@ -123,7 +123,7 @@ public class MobileAppController extends BaseController { return tbMobileAppService.save(mobileApp, getCurrentUser()); } - @ApiOperation(value = "Get mobile app infos (getTenantMobileAppInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Get mobile app infos (getTenantMobileApps)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/mobile/app") public PageData getTenantMobileApps(@Parameter(description = "Platform type: ANDROID or IOS") @@ -142,7 +142,7 @@ public class MobileAppController extends BaseController { return mobileAppService.findMobileAppsByTenantId(getTenantId(), platformType, pageLink); } - @ApiOperation(value = "Get mobile info by id (getMobileAppInfoById)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Get mobile info by id (getMobileAppById)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/mobile/app/{id}") public MobileApp getMobileAppById(@PathVariable UUID id) throws ThingsboardException { diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java index 72c7f15a3b..17bb87233e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -153,12 +154,10 @@ public class NotificationTargetController extends BaseController { return notificationTargetService.findRecipientsForNotificationTargetConfig(user.getTenantId(), (PlatformUsersNotificationTargetConfig) notificationTarget.getConfiguration(), pageLink); } - @ApiOperation(value = "Get notification targets by ids (getNotificationTargetsByIds)", - notes = "Returns the list of notification targets found by provided ids." + - SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @Hidden @GetMapping(value = "/targets", params = {"ids"}) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public List getNotificationTargetsByIds(@Parameter(description = "Comma-separated list of uuids representing targets ids", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + public List getNotificationTargetsByIdsV1(@Parameter(description = "Comma-separated list of uuids representing targets ids", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("ids") UUID[] ids, @AuthenticationPrincipal SecurityUser user) { // PE: generic permission @@ -166,6 +165,17 @@ public class NotificationTargetController extends BaseController { return notificationTargetService.findNotificationTargetsByTenantIdAndIds(user.getTenantId(), targetsIds); } + @ApiOperation(value = "Get notification targets by ids (getNotificationTargetsByIds)", + notes = "Returns the list of notification targets found by provided ids." + + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/targets/list") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + public List getNotificationTargetsByIds(@Parameter(description = "Comma-separated list of uuids representing targets ids", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("ids") UUID[] ids, + @AuthenticationPrincipal SecurityUser user) { + return getNotificationTargetsByIdsV1(ids, user); + } + @ApiOperation(value = "Get notification targets (getNotificationTargets)", notes = "Returns the page of notification targets owned by sysadmin or tenant." + NEW_LINE + PAGE_DATA_PARAMETERS + @@ -188,13 +198,10 @@ public class NotificationTargetController extends BaseController { return notificationTargetService.findNotificationTargetsByTenantId(user.getTenantId(), pageLink); } - @ApiOperation(value = "Get notification targets by supported notification type (getNotificationTargetsBySupportedNotificationType)", - notes = "Returns the page of notification targets filtered by notification type that they can be used for." + NEW_LINE + - PAGE_DATA_PARAMETERS + - SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @Hidden @GetMapping(value = "/targets", params = "notificationType") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public PageData getNotificationTargetsBySupportedNotificationType(@RequestParam int pageSize, + public PageData getNotificationTargetsBySupportedNotificationTypeV1(@RequestParam int pageSize, @RequestParam int page, @RequestParam(required = false) String textSearch, @RequestParam(required = false) String sortProperty, @@ -206,6 +213,22 @@ public class NotificationTargetController extends BaseController { return notificationTargetService.findNotificationTargetsByTenantIdAndSupportedNotificationType(user.getTenantId(), notificationType, pageLink); } + @ApiOperation(value = "Get notification targets by supported notification type (getNotificationTargetsBySupportedNotificationType)", + notes = "Returns the page of notification targets filtered by notification type that they can be used for." + NEW_LINE + + PAGE_DATA_PARAMETERS + + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/targets/notificationType/{notificationType}") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + public PageData getNotificationTargetsBySupportedNotificationType(@PathVariable NotificationType notificationType, + @RequestParam int pageSize, + @RequestParam int page, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String sortProperty, + @RequestParam(required = false) String sortOrder, + @AuthenticationPrincipal SecurityUser user) throws ThingsboardException { + return getNotificationTargetsBySupportedNotificationTypeV1(pageSize, page, textSearch, sortProperty, sortOrder, notificationType, user); + } + @ApiOperation(value = "Delete notification target by id (deleteNotificationTargetById)", notes = "Deletes notification target by its id." + NEW_LINE + "This target cannot be referenced by existing scheduled notification requests or any notification rules." + diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java index 8891fcc64e..6c773c9509 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java @@ -71,12 +71,12 @@ public class OAuth2ConfigTemplateController extends BaseController { oAuth2ConfigTemplateService.deleteClientRegistrationTemplateById(clientRegistrationTemplateId); } - @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getOAuth2ClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, notes = OAUTH2_CLIENT_REGISTRATION_TEMPLATE_DEFINITION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(method = RequestMethod.GET, produces = "application/json") @ResponseBody - public List getClientRegistrationTemplates() throws ThingsboardException { + public List getOAuth2ClientRegistrationTemplates() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.OAUTH2_CONFIGURATION_TEMPLATE, Operation.READ); return oAuth2ConfigTemplateService.findAllClientRegistrationTemplates(); } 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 71b8ac27a9..5e51e0bbb6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -115,32 +116,40 @@ 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); } + @Hidden + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/oauth2/client/infos", params = {"clientIds"}) + public List findTenantOAuth2ClientInfosByIdsV1( + @RequestParam("clientIds") UUID[] clientIds) throws ThingsboardException { + List oAuth2ClientIds = getOAuth2ClientIds(clientIds); + return oAuth2ClientService.findOAuth2ClientInfosByIds(getTenantId(), oAuth2ClientIds); + } + @ApiOperation(value = "Get OAuth2 Client infos By Ids (findTenantOAuth2ClientInfosByIds)", notes = "Fetch OAuth2 Client info objects based on the provided ids. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @GetMapping(value = "/oauth2/client/infos", params = {"clientIds"}) + @GetMapping(value = "/oauth2/client/list") public List findTenantOAuth2ClientInfosByIds( @Parameter(description = "A list of oauth2 ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("clientIds") UUID[] clientIds) throws ThingsboardException { - List oAuth2ClientIds = getOAuth2ClientIds(clientIds); - return oAuth2ClientService.findOAuth2ClientInfosByIds(getTenantId(), oAuth2ClientIds); + return findTenantOAuth2ClientInfosByIdsV1(clientIds); } @ApiOperation(value = "Get OAuth2 Client by id (getOAuth2ClientById)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) diff --git a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java index 4967023ec0..ab93a29db5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java @@ -181,25 +181,25 @@ public class OtaPackageController extends BaseController { return checkNotNull(otaPackageService.findTenantOtaPackagesByTenantId(getTenantId(), pageLink)); } - @ApiOperation(value = "Get OTA Package Infos (getOtaPackages)", + @ApiOperation(value = "Get OTA Package Infos by device profile and type (getOtaPackagesByDeviceProfileAndType)", notes = "Returns a page of OTA Package Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/otaPackages/{deviceProfileId}/{type}") - public PageData getOtaPackages(@Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) - @PathVariable("deviceProfileId") String strDeviceProfileId, - @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = {"FIRMWARE", "SOFTWARE"})) - @PathVariable("type") String strType, - @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) - @RequestParam int pageSize, - @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) - @RequestParam int page, - @Parameter(description = OTA_PACKAGE_TEXT_SEARCH_DESCRIPTION) - @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "type", "title", "version", "tag", "url", "fileName", "dataSize", "checksum"})) - @RequestParam(required = false) String sortProperty, - @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) - @RequestParam(required = false) String sortOrder) throws ThingsboardException { + public PageData getOtaPackagesByDeviceProfileAndType(@Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable("deviceProfileId") String strDeviceProfileId, + @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = {"FIRMWARE", "SOFTWARE"})) + @PathVariable("type") String strType, + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = OTA_PACKAGE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "type", "title", "version", "tag", "url", "fileName", "dataSize", "checksum"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { checkParameter("deviceProfileId", strDeviceProfileId); checkParameter("type", strType); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); diff --git a/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java index d266b684d8..6b90fb142f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java @@ -129,7 +129,7 @@ public class QrCodeSettingsController extends BaseController { return qrCodeSettingService.saveQrCodeSettings(currentUser.getTenantId(), qrCodeSettings); } - @ApiOperation(value = "Get Mobile application settings (getMobileAppSettings)", + @ApiOperation(value = "Get Mobile application settings (getQrCodeSettings)", notes = "The response payload contains configuration for android/iOS applications and platform qr code widget settings." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/api/mobile/qr/settings") diff --git a/application/src/main/java/org/thingsboard/server/controller/QueueController.java b/application/src/main/java/org/thingsboard/server/controller/QueueController.java index 2d584a6e27..dd2cc19fd6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/QueueController.java +++ b/application/src/main/java/org/thingsboard/server/controller/QueueController.java @@ -19,7 +19,9 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +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.RequestMethod; @@ -65,8 +67,7 @@ public class QueueController extends BaseController { notes = "Returns a page of queues registered in the platform. " + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/queues", params = {"serviceType", "pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/queues") public PageData getTenantQueuesByServiceType(@Parameter(description = QUEUE_SERVICE_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"TB-RULE-ENGINE", "TB-CORE", "TB-TRANSPORT", "JS-EXECUTOR"}, requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam String serviceType, @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @@ -122,8 +123,7 @@ public class QueueController extends BaseController { "Remove 'id', 'tenantId' from the request body example (below) to create new Queue entity. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/queues", params = {"serviceType"}, method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/queues") public Queue saveQueue(@Parameter(description = "A JSON value representing the queue.") @RequestBody Queue queue, @Parameter(description = QUEUE_SERVICE_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"TB-RULE-ENGINE", "TB-CORE", "TB-TRANSPORT", "JS-EXECUTOR"}, requiredMode = Schema.RequiredMode.REQUIRED)) diff --git a/application/src/main/java/org/thingsboard/server/controller/QueueStatsController.java b/application/src/main/java/org/thingsboard/server/controller/QueueStatsController.java index 1ccc35b875..57ec9a43c9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/QueueStatsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/QueueStatsController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -85,12 +86,10 @@ public class QueueStatsController extends BaseController { return checkNotNull(queueStatsService.findQueueStatsById(getTenantId(), queueStatsId)); } - @ApiOperation(value = "Get QueueStats By Ids (getQueueStatsByIds)", - notes = "Fetch the Queue stats objects based on the provided ids. ") + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/queueStats", params = {"queueStatsIds"}) - public List getQueueStatsByIds( - @Parameter(description = "A list of queue stats ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + public List getQueueStatsByIdsV1( @RequestParam("queueStatsIds") String[] strQueueStatsIds) throws ThingsboardException { checkArrayParameter("queueStatsIds", strQueueStatsIds); List queueStatsIds = new ArrayList<>(); @@ -99,4 +98,14 @@ public class QueueStatsController extends BaseController { } return queueStatsService.findQueueStatsByIds(getTenantId(), queueStatsIds); } + + @ApiOperation(value = "Get QueueStats By Ids (getQueueStatsByIds)", + notes = "Fetch the Queue stats objects based on the provided ids. ") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/queueStats/list") + public List getQueueStatsByIds( + @Parameter(description = "A list of queue stats ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("queueStatsIds") String[] strQueueStatsIds) throws ThingsboardException { + return getQueueStatsByIdsV1(strQueueStatsIds); + } } 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 dc17136d63..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; @@ -43,26 +45,28 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CU @Slf4j public class RpcV1Controller extends AbstractRpcController { - @ApiOperation(value = "Send one-way RPC request (handleOneWayDeviceRPCRequest)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Send one-way RPC request (handleOneWayDeviceRPCRequestV1)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/oneway/{deviceId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleOneWayDeviceRPCRequest( + 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); } - @ApiOperation(value = "Send two-way RPC request (handleTwoWayDeviceRPCRequest)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Send two-way RPC request (handleTwoWayDeviceRPCRequestV1)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/twoway/{deviceId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleTwoWayDeviceRPCRequest( + 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 412e775327..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; @@ -113,7 +114,7 @@ public class RpcV2Controller extends AbstractRpcController { private static final String TWO_WAY_RPC_REQUEST_DESCRIPTION = "Sends the two-way remote-procedure call (RPC) request to device. " + RPC_REQUEST_DESCRIPTION + TWO_WAY_RPC_RESULT + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; - @ApiOperation(value = "Send one-way RPC request", notes = ONE_WAY_RPC_REQUEST_DESCRIPTION) + @ApiOperation(value = "Send one-way RPC request (handleOneWayDeviceRPCRequestV2)", notes = ONE_WAY_RPC_REQUEST_DESCRIPTION) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Persistent RPC request was saved to the database or lightweight RPC request was sent to the device."), @ApiResponse(responseCode = "400", description = "Invalid structure of the request."), @@ -124,15 +125,16 @@ public class RpcV2Controller extends AbstractRpcController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/oneway/{deviceId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleOneWayDeviceRPCRequest( + 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); } - @ApiOperation(value = "Send two-way RPC request", notes = TWO_WAY_RPC_REQUEST_DESCRIPTION) + @ApiOperation(value = "Send two-way RPC request (handleTwoWayDeviceRPCRequestV2)", notes = TWO_WAY_RPC_REQUEST_DESCRIPTION) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Persistent RPC request was saved to the database or lightweight RPC response received."), @ApiResponse(responseCode = "400", description = "Invalid structure of the request."), @@ -143,10 +145,11 @@ public class RpcV2Controller extends AbstractRpcController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/twoway/{deviceId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleTwoWayDeviceRPCRequest( + 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/RuleChainController.java b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java index accec11afe..0ac6fcdac1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -229,12 +230,12 @@ public class RuleChainController extends BaseController { return tbRuleChainService.save(ruleChain, getCurrentUser()); } - @ApiOperation(value = "Create Default Rule Chain", + @ApiOperation(value = "Create Default Rule Chain (setDeviceDefaultRuleChain)", notes = "Create rule chain from template, based on the specified name in the request. " + "Creates the rule chain based on the template that is used to create root rule chain. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping("/ruleChain/device/default") - public RuleChain saveRuleChain( + public RuleChain setDeviceDefaultRuleChain( @Parameter(description = "A JSON value representing the request.") @RequestBody DefaultRuleChainCreateRequest request) throws Exception { checkNotNull(request); @@ -281,7 +282,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chains (getRuleChains)", notes = "Returns a page of Rule Chains owned by tenant. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/ruleChains", params = {"pageSize", "page"}) + @GetMapping(value = "/ruleChains") public PageData getRuleChains( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -347,7 +348,7 @@ public class RuleChainController extends BaseController { notes = TEST_SCRIPT_FUNCTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping("/ruleChain/testScript") - public JsonNode testScript( + public JsonNode testRuleChainScript( @Parameter(description = "Script language: JS or TBEL") @RequestParam(required = false) ScriptLanguage scriptLang, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test JS request. See API call description above.") @@ -409,7 +410,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Export Rule Chains", notes = "Exports all tenant rule chains as one JSON." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/ruleChains/export", params = {"limit"}) + @GetMapping(value = "/ruleChains/export") public RuleChainData exportRuleChains( @Parameter(description = "A limit of rule chains to export.", required = true) @RequestParam("limit") int limit) throws ThingsboardException { @@ -505,7 +506,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Edge Rule Chains (getEdgeRuleChains)", notes = "Returns a page of Rule Chains assigned to the specified edge. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/edge/{edgeId}/ruleChains", params = {"pageSize", "page"}) + @GetMapping(value = "/edge/{edgeId}/ruleChains") public PageData getEdgeRuleChains( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, @@ -582,14 +583,10 @@ public class RuleChainController extends BaseController { return checkNotNull(result); } - @ApiOperation(value = "Get Rule Chains By Ids (getRuleChainsByIds)", - notes = "Requested rule chains must be owned by tenant which is performing the request. " + - NEW_LINE) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/ruleChains", params = {"ruleChainIds"}) - public List getRuleChainsByIds( - @Parameter(description = "A list of rule chain ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) - @RequestParam("ruleChainIds") Set ruleChainUUIDs) throws Exception { + public List getRuleChainsByIdsV1(@RequestParam("ruleChainIds") Set ruleChainUUIDs) throws Exception { TenantId tenantId = getCurrentUser().getTenantId(); List ruleChainIds = new ArrayList<>(); for (UUID ruleChainUUID : ruleChainUUIDs) { @@ -598,4 +595,15 @@ public class RuleChainController extends BaseController { return ruleChainService.findRuleChainsByIds(tenantId, ruleChainIds); } + @ApiOperation(value = "Get Rule Chains By Ids (getRuleChainsByIds)", + notes = "Requested rule chains must be owned by tenant which is performing the request. " + + NEW_LINE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/ruleChains/list") + public List getRuleChainsByIds( + @Parameter(description = "A list of rule chain ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("ruleChainIds") Set ruleChainUUIDs) throws Exception { + return getRuleChainsByIdsV1(ruleChainUUIDs); + } + } 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 2b4489e387..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; @@ -77,7 +79,7 @@ public class RuleEngineController extends BaseController { @Autowired private AccessValidator accessValidator; - @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequest)", + @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequestForUser)", notes = MSG_DESCRIPTION_PREFIX + "Uses current User Id ( the one which credentials is used to perform the request) as the Rule Engine message originator. " + MSG_DESCRIPTION + @@ -86,13 +88,14 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleRuleEngineRequest( - @Parameter(description = "A JSON value representing the message.", required = true) + public DeferredResult handleRuleEngineRequestForUser( + @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 handleRuleEngineRequest(null, null, null, defaultResponseTimeout, requestBody); + return handleRuleEngineRequestForEntityWithQueueAndTimeout(null, null, null, defaultResponseTimeout, requestBody); } - @ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequest)", + @ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequestForEntity)", notes = MSG_DESCRIPTION_PREFIX + "Uses specified Entity Id as the Rule Engine message originator. " + MSG_DESCRIPTION + @@ -101,17 +104,18 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleRuleEngineRequest( + public DeferredResult handleRuleEngineRequestForEntity( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @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 handleRuleEngineRequest(entityType, entityIdStr, null, defaultResponseTimeout, requestBody); + return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, defaultResponseTimeout, requestBody); } - @ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequest)", + @ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequestForEntityWithTimeout)", notes = MSG_DESCRIPTION_PREFIX + "Uses specified Entity Id as the Rule Engine message originator. " + MSG_DESCRIPTION + @@ -120,19 +124,20 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}/{timeout}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleRuleEngineRequest( + public DeferredResult handleRuleEngineRequestForEntityWithTimeout( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @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 handleRuleEngineRequest(entityType, entityIdStr, null, timeout, requestBody); + return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, timeout, requestBody); } - @ApiOperation(value = "Push entity message with timeout and specified queue to the rule engine (handleRuleEngineRequest)", + @ApiOperation(value = "Push entity message with timeout and specified queue to the rule engine (handleRuleEngineRequestForEntityWithQueueAndTimeout)", notes = MSG_DESCRIPTION_PREFIX + "Uses specified Entity Id as the Rule Engine message originator. " + MSG_DESCRIPTION + @@ -142,7 +147,7 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}/{queueName}/{timeout}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleRuleEngineRequest( + public DeferredResult handleRuleEngineRequestForEntityWithQueueAndTimeout( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @@ -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(); @@ -244,5 +250,7 @@ public class RuleEngineController extends BaseController { response != null ? response.getData() : ""); } - private record LocalRequestMetaData(TbMsg request, SecurityUser user, DeferredResult responseWriter) {} + private record LocalRequestMetaData(TbMsg request, SecurityUser user, + DeferredResult responseWriter) { + } } diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index a7aaf37ffd..429f9b9cf2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -118,7 +119,7 @@ public class TbResourceController extends BaseController { .body(resource); } - @ApiOperation(value = "Download resource (downloadResource)", + @ApiOperation(value = "Download resource (downloadResourceIfChanged)", notes = "Download resource with a given type and key for the given scope" + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/resource/{resourceType}/{scope}/{key}") @@ -336,11 +337,10 @@ public class TbResourceController extends BaseController { } } - @ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)") + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/resource", params = {"resourceIds"}) - public List getSystemOrTenantResourcesByIds( - @Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) + public List getSystemOrTenantResourcesByIdsV1( @RequestParam("resourceIds") Set resourceUuids) throws ThingsboardException { SecurityUser user = getCurrentUser(); List resourceIds = new ArrayList<>(); @@ -350,7 +350,16 @@ public class TbResourceController extends BaseController { return resourceService.findSystemOrTenantResourcesByIds(user.getTenantId(), resourceIds); } - @ApiOperation(value = "Get All Resource Infos (getAllResources)", + @ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/resource/list") + public List getSystemOrTenantResourcesByIds( + @Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) + @RequestParam("resourceIds") Set resourceUuids) throws ThingsboardException { + return getSystemOrTenantResourcesByIdsV1(resourceUuids); + } + + @ApiOperation(value = "Get All Resource Infos (getTenantResources)", notes = "Returns a page of Resource Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") 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 856b17fc61..754c9253d9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -24,7 +24,12 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; +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; @@ -166,7 +171,8 @@ public class TelemetryController extends BaseController { "\n\n * SERVER_SCOPE - supported for all entity types;" + "\n * CLIENT_SCOPE - supported for devices;" + "\n * SHARED_SCOPE - supported for devices. " - + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(type = "string"))))) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/keys/attributes") public DeferredResult getAttributeKeys( @@ -180,7 +186,8 @@ public class TelemetryController extends BaseController { "\n\n * SERVER_SCOPE - supported for all entity types;" + "\n * CLIENT_SCOPE - supported for devices;" + "\n * SHARED_SCOPE - supported for devices. " - + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(type = "string"))))) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/keys/attributes/{scope}") public DeferredResult getAttributeKeysByScope( @@ -197,13 +204,18 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_START + ATTRIBUTE_DATA_EXAMPLE + MARKDOWN_CODE_BLOCK_END - + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = AttributeData.class))))) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/values/attributes") public DeferredResult getAttributes( @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 = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); SecurityUser user = getCurrentUser(); @@ -220,7 +232,11 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_START + ATTRIBUTE_DATA_EXAMPLE + MARKDOWN_CODE_BLOCK_END - + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = AttributeData.class))))) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/values/attributes/{scope}") public DeferredResult getAttributesByScope( @@ -228,6 +244,7 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); SecurityUser user = getCurrentUser(); @@ -237,7 +254,8 @@ public class TelemetryController extends BaseController { @ApiOperation(value = "Get time series keys (getTimeseriesKeys)", notes = "Returns a set of unique time series key names for the selected entity. " + - "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(type = "string"))))) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/keys/timeseries") public DeferredResult getTimeseriesKeys( @@ -258,7 +276,11 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_START + LATEST_TS_STRICT_DATA_EXAMPLE + MARKDOWN_CODE_BLOCK_END - + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = "object", additionalPropertiesSchema = TsData[].class)))) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/values/timeseries") public DeferredResult getLatestTimeseries( @@ -267,6 +289,7 @@ public class TelemetryController extends BaseController { @Parameter(description = TELEMETRY_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, @Parameter(description = STRICT_DATA_TYPES_DESCRIPTION) @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); SecurityUser user = getCurrentUser(); @@ -274,7 +297,32 @@ public class TelemetryController extends BaseController { (result, tenantId, entityId) -> getLatestTimeseriesValuesCallback(result, user, entityId, keys, useStrictDataTypes)); } - @ApiOperation(value = "Get time series data (getTimeseries)", + @Hidden + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/{entityType}/{entityId}/values/timeseries", params = {"startTs", "endTs"}) + public DeferredResult getTimeseries( + @PathVariable("entityType") String entityType, + @PathVariable("entityId") String entityIdStr, + @RequestParam(name = "keys", required = false) String keysStr, + @RequestParam(name = "startTs") Long startTs, + @RequestParam(name = "endTs") Long endTs, + @RequestParam(name = "intervalType", required = false) IntervalType intervalType, + @RequestParam(name = "interval", defaultValue = "0") Long interval, + @RequestParam(name = "timeZone", required = false) String timeZone, + @RequestParam(name = "limit", defaultValue = "100") Integer limit, + @RequestParam(name = "agg", defaultValue = "NONE") String aggStr, + @RequestParam(name = "orderBy", defaultValue = "DESC") String orderBy, + @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes, + @RequestParam MultiValueMap params) throws ThingsboardException { + List keys = getKeys(keysStr, params); + DeferredResult response = new DeferredResult<>(); + Futures.addCallback(tbTelemetryService.getTimeseries(EntityIdFactory.getByTypeAndId(entityType, entityIdStr), keys, startTs, endTs, + intervalType, interval, timeZone, limit, Aggregation.valueOf(aggStr), orderBy, useStrictDataTypes, getCurrentUser()), + getTsKvListCallback(response, useStrictDataTypes), MoreExecutors.directExecutor()); + return response; + } + + @ApiOperation(value = "Get time series data (getTimeseriesHistory)", notes = "Returns a range of time series values for specified entity. " + "Returns not aggregated data by default. " + "Use aggregation function ('agg') and aggregation interval ('interval') to enable aggregation of the results on the database / server side. " + @@ -282,10 +330,14 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_START + TS_STRICT_DATA_EXAMPLE + MARKDOWN_CODE_BLOCK_END - + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = "object", additionalPropertiesSchema = TsData[].class)))) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/{entityType}/{entityId}/values/timeseries", params = {"startTs", "endTs"}) - public DeferredResult getTimeseries( + @GetMapping(value = "/{entityType}/{entityId}/values/timeseries/history") + public DeferredResult getTimeseriesHistory( @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_KEYS_BASE_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, @@ -310,13 +362,9 @@ public class TelemetryController extends BaseController { @RequestParam(name = "orderBy", defaultValue = "DESC") String orderBy, @Parameter(description = STRICT_DATA_TYPES_DESCRIPTION) @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { - List keys = getKeys(keysStr, params); - DeferredResult response = new DeferredResult<>(); - Futures.addCallback(tbTelemetryService.getTimeseries(EntityIdFactory.getByTypeAndId(entityType, entityIdStr), keys, startTs, endTs, - intervalType, interval, timeZone, limit, Aggregation.valueOf(aggStr), orderBy, useStrictDataTypes, getCurrentUser()), - getTsKvListCallback(response, useStrictDataTypes), MoreExecutors.directExecutor()); - return response; + return getTimeseries(entityType, entityIdStr, keysStr, startTs, endTs, intervalType, interval, timeZone, limit, aggStr, orderBy, useStrictDataTypes, params); } @ApiOperation(value = "Save device attributes (saveDeviceAttributes)", @@ -338,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); @@ -363,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); @@ -388,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); @@ -412,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); } @@ -436,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); } @@ -449,6 +502,9 @@ public class TelemetryController extends BaseController { " Use 'rewriteLatestIfDeleted' to rewrite latest value (stored in separate table for performance) if the value's timestamp matches the time-range and 'deleteLatest' param is true." + " The replacement value will be fetched from the 'time series' table, and its timestamp will be the most recent one before the defined time-range. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Time series for the selected keys in the request was removed. " + "Platform creates an audit log event about entity time series removal with action type 'TIMESERIES_DELETED'."), @@ -473,6 +529,7 @@ public class TelemetryController extends BaseController { @RequestParam(name = "deleteLatest", required = false, defaultValue = "true") boolean deleteLatest, @Parameter(description = "If the parameter is set to true, the latest telemetry will be rewritten in case that current latest value was removed, otherwise, in case that parameter is set to false the new latest value will not set.") @RequestParam(name = "rewriteLatestIfDeleted", defaultValue = "false") boolean rewriteLatestIfDeleted, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); @@ -530,6 +587,9 @@ public class TelemetryController extends BaseController { @ApiOperation(value = "Delete device attributes (deleteDeviceAttributes)", notes = "Delete device attributes using provided Device Id, scope and a list of keys. " + "Referencing a non-existing Device Id will cause an error" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Device attributes was removed for the selected keys in the request. " + "Platform creates an audit log event about device attributes removal with action type 'ATTRIBUTES_DELETED'."), @@ -544,6 +604,7 @@ public class TelemetryController extends BaseController { @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(DEVICE_ID) String deviceIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); @@ -552,9 +613,13 @@ public class TelemetryController extends BaseController { @ApiOperation(value = "Delete entity attributes (deleteEntityAttributes)", notes = "Delete entity attributes using provided Entity Id, scope and a list of keys. " + + "This operation is idempotent: keys that do not exist are silently ignored and the response is still 200 OK. " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Entity attributes was removed for the selected keys in the request. " + + @ApiResponse(responseCode = "200", description = "Entity attributes were removed for the selected keys in the request (keys that did not exist are silently ignored). " + "Platform creates an audit log event about entity attributes removal with action type 'ATTRIBUTES_DELETED'."), @ApiResponse(responseCode = "400", description = "Platform returns a bad request in case if keys or scope are not specified."), @ApiResponse(responseCode = "401", description = "User is not authorized to delete entity attributes for selected entity. Most likely, User belongs to different Customer or Tenant."), @@ -568,6 +633,7 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, required = true, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"})) @PathVariable("scope") AttributeScope scope, @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantController.java b/application/src/main/java/org/thingsboard/server/controller/TenantController.java index efa1d36cff..dcd53f7762 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -22,7 +23,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -133,7 +133,7 @@ public class TenantController extends BaseController { @ApiOperation(value = "Get Tenants (getTenants)", notes = "Returns a page of tenants registered in the platform. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenants", params = {"pageSize", "page"}) + @GetMapping(value = "/tenants") public PageData getTenants( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -152,7 +152,7 @@ public class TenantController extends BaseController { @ApiOperation(value = "Get Tenants Info (getTenants)", notes = "Returns a page of tenant info objects registered in the platform. " + TENANT_INFO_DESCRIPTION + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenantInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/tenantInfos") public PageData getTenantInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -169,9 +169,10 @@ public class TenantController extends BaseController { return checkNotNull(tenantService.findTenantInfos(pageLink)); } + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/tenants", params = {"tenantIds"}) - public List getTenantsByIds( + public List getTenantsByIdsV1( @Parameter(description = "A list of tenant ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) @RequestParam("tenantIds") Set tenantUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); @@ -183,6 +184,15 @@ public class TenantController extends BaseController { return filterTenantsByReadPermission(tenants); } + @ApiOperation(value = "Get Tenants list (getTenantsByIds)") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/tenants/list") + public List getTenantsByIds( + @Parameter(description = "A list of tenant ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) + @RequestParam("tenantIds") Set tenantUUIDs) throws ThingsboardException { + return getTenantsByIdsV1(tenantUUIDs); + } + private List filterTenantsByReadPermission(List tenants) { return tenants.stream().filter(tenant -> { try { diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java index c0f7b9eb40..a47768e91c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -191,7 +192,7 @@ public class TenantProfileController extends BaseController { oldProfile = checkTenantProfileId(tenantProfile.getId(), Operation.WRITE); } - return tbTenantProfileService.save(getTenantId(), tenantProfile, oldProfile); + return tbTenantProfileService.save(getTenantId(), tenantProfile, oldProfile, getCurrentUser()); } @ApiOperation(value = "Delete Tenant Profile (deleteTenantProfile)", @@ -204,7 +205,7 @@ public class TenantProfileController extends BaseController { checkParameter("tenantProfileId", strTenantProfileId); TenantProfileId tenantProfileId = new TenantProfileId(toUUID(strTenantProfileId)); TenantProfile profile = checkTenantProfileId(tenantProfileId, Operation.DELETE); - tbTenantProfileService.delete(getTenantId(), profile); + tbTenantProfileService.delete(getTenantId(), profile, getCurrentUser()); } @ApiOperation(value = "Make tenant profile default (setDefaultTenantProfile)", @@ -217,7 +218,7 @@ public class TenantProfileController extends BaseController { checkParameter("tenantProfileId", strTenantProfileId); TenantProfileId tenantProfileId = new TenantProfileId(toUUID(strTenantProfileId)); TenantProfile tenantProfile = checkTenantProfileId(tenantProfileId, Operation.WRITE); - tenantProfileService.setDefaultTenantProfile(getTenantId(), tenantProfileId); + tenantProfile = tbTenantProfileService.setDefaultTenantProfile(getTenantId(), tenantProfile, getCurrentUser()); return tenantProfile; } @@ -242,7 +243,7 @@ public class TenantProfileController extends BaseController { @ApiOperation(value = "Get Tenant Profiles Info (getTenantProfileInfos)", notes = "Returns a page of tenant profile info objects registered in the platform. " + TENANT_PROFILE_INFO_DESCRIPTION + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenantProfileInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/tenantProfileInfos") public PageData getTenantProfileInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -258,11 +259,19 @@ public class TenantProfileController extends BaseController { return checkNotNull(tenantProfileService.findTenantProfileInfos(getTenantId(), pageLink)); } + @Hidden @GetMapping(value = "/tenantProfiles", params = {"ids"}) @PreAuthorize("hasAuthority('SYS_ADMIN')") - public List getTenantProfilesByIds(@Parameter(description = "Comma-separated list of tenant profile ids", array = @ArraySchema(schema = @Schema(type = "string"))) - @RequestParam("ids") UUID[] ids) { + public List getTenantProfilesByIds(@RequestParam("ids") UUID[] ids) { return tenantProfileService.findTenantProfilesByIds(TenantId.SYS_TENANT_ID, ids); } + @ApiOperation(value = "Get Tenant Profile list (getTenantProfileList)") + @GetMapping(value = "/tenantProfiles/list") + @PreAuthorize("hasAuthority('SYS_ADMIN')") + public List getTenantProfileList(@Parameter(description = "Comma-separated list of tenant profile ids", array = @ArraySchema(schema = @Schema(type = "string"))) + @RequestParam("ids") UUID[] ids) { + return getTenantProfilesByIds(ids); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java index fddeabfeaa..3b15085651 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -16,7 +16,11 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +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; import jakarta.validation.Valid; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -187,7 +191,7 @@ public class TwoFactorAuthConfigController extends BaseController { return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user, providerType); } - @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = + @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviderTypes)", notes = "Get the list of provider types available for user to use (the ones configured by tenant or sysadmin).\n" + "Example of response:\n" + "```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" + @@ -195,7 +199,7 @@ public class TwoFactorAuthConfigController extends BaseController { ) @GetMapping("/providers") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") - public List getAvailableTwoFaProviders() throws ThingsboardException { + public List getAvailableTwoFaProviderTypes() throws ThingsboardException { return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), true) .map(PlatformTwoFaSettings::getProviders).orElse(Collections.emptyList()).stream() .map(TwoFaProviderConfig::getProviderType) @@ -261,6 +265,7 @@ public class TwoFactorAuthConfigController extends BaseController { } @Data + @Schema public static class TwoFaAccountConfigUpdateRequest { private boolean useByDefault; } diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java index e26932ff22..8007a77cb1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.servlet.http.HttpServletRequest; import lombok.AllArgsConstructor; import lombok.Builder; @@ -99,7 +100,7 @@ public class TwoFactorAuthController extends BaseController { } } - @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = + @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviderInfos)", notes = "Get the list of 2FA provider infos available for user to use. Example:\n" + "```\n[\n" + " {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" + @@ -108,7 +109,7 @@ public class TwoFactorAuthController extends BaseController { "]\n```") @GetMapping("/providers") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public List getAvailableTwoFaProviders() throws ThingsboardException { + public List getAvailableTwoFaProviderInfos() throws ThingsboardException { SecurityUser user = getCurrentUser(); Optional platformTwoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(user.getTenantId(), true); return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user) @@ -166,6 +167,7 @@ public class TwoFactorAuthController extends BaseController { @Builder public static class TwoFaProviderInfo { private TwoFaProviderType type; + @JsonProperty("default") private boolean isDefault; private String contact; private Integer minVerificationCodeSendPeriod; 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 878a3287bc..b919b664e5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -273,7 +274,7 @@ public class UserController extends BaseController { notes = "Returns a page of users owned by tenant or customer. The scope depends on authority of the user that performs the request." + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/users", params = {"pageSize", "page"}) + @GetMapping(value = "/users") public PageData getUsers( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -334,7 +335,7 @@ public class UserController extends BaseController { @ApiOperation(value = "Get Tenant Users (getTenantAdmins)", notes = "Returns a page of users owned by tenant. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenant/{tenantId}/users", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/{tenantId}/users") public PageData getTenantAdmins( @Parameter(description = TENANT_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TENANT_ID) String strTenantId, @@ -357,7 +358,7 @@ public class UserController extends BaseController { @ApiOperation(value = "Get Customer Users (getCustomerUsers)", notes = "Returns a page of users owned by customer. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/customer/{customerId}/users", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/users") public PageData getCustomerUsers( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -404,7 +405,7 @@ public class UserController extends BaseController { "Search is been executed by email, firstName and lastName fields. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/users/assign/{alarmId}", params = {"pageSize", "page"}) + @GetMapping(value = "/users/assign/{alarmId}") public PageData getUsersForAssign( @Parameter(description = ALARM_ID_PARAM_DESCRIPTION, required = true) @PathVariable("alarmId") String strAlarmId, @@ -456,10 +457,7 @@ public class UserController extends BaseController { return userSettingsService.saveUserSettings(currentUser.getTenantId(), userSettings).getSettings(); } - @ApiOperation(value = "Update user settings (saveUserSettings)", - notes = "Update user settings for authorized user. Only specified json elements will be updated." + - "Example: you have such settings: {A:5, B:{C:10, D:20}}. Updating it with {B:{C:10, D:30}} will result in" + - "{A:5, B:{C:10, D:30}}. The same could be achieved by putting {B.D:30}") + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PutMapping(value = "/user/settings") public void putUserSettings(@RequestBody JsonNode settings) throws ThingsboardException { @@ -467,8 +465,17 @@ public class UserController extends BaseController { userSettingsService.updateUserSettings(currentUser.getTenantId(), currentUser.getId(), UserSettingsType.GENERAL, settings); } - @ApiOperation(value = "Get user settings (getUserSettings)", - notes = "Fetch the User settings based on authorized user. ") + @ApiOperation(value = "Update user settings (putGeneralUserSettings)", + notes = "Update user settings for authorized user. Only specified json elements will be updated." + + "Example: you have such settings: {A:5, B:{C:10, D:20}}. Updating it with {B:{C:10, D:30}} will result in" + + "{A:5, B:{C:10, D:30}}. The same could be achieved by putting {B.D:30}") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PutMapping(value = "/user/settings/general") + public void putGeneralUserSettings(@RequestBody JsonNode settings) throws ThingsboardException { + putUserSettings(settings); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/user/settings") public JsonNode getUserSettings() throws ThingsboardException { @@ -478,20 +485,28 @@ public class UserController extends BaseController { return userSettings == null ? JacksonUtil.newObjectNode() : userSettings.getSettings(); } - @ApiOperation(value = "Delete user settings (deleteUserSettings)", + @ApiOperation(value = "Get user settings (getGeneralUserSettings)", + notes = "Fetch the User settings based on authorized user. ") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/user/settings/general") + public JsonNode getGeneralUserSettings() throws ThingsboardException { + return getUserSettings(); + } + + @ApiOperation(value = "Delete user settings (deleteGeneralUserSettings)", notes = "Delete user settings by specifying list of json element xpaths. \n " + "Example: to delete B and C element in { \"A\": {\"B\": 5}, \"C\": 15} send A.B,C in jsonPaths request parameter") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @DeleteMapping(value = "/user/settings/{paths}") - public void deleteUserSettings(@Parameter(description = PATHS) - @PathVariable(PATHS) String paths) throws ThingsboardException { + public void deleteGeneralUserSettings(@Parameter(description = PATHS) + @PathVariable(PATHS) String paths) throws ThingsboardException { checkParameter(USER_ID, paths); SecurityUser currentUser = getCurrentUser(); userSettingsService.deleteUserSettings(currentUser.getTenantId(), currentUser.getId(), UserSettingsType.GENERAL, Arrays.asList(paths.split(","))); } - @ApiOperation(value = "Update user settings (saveUserSettings)", + @ApiOperation(value = "Update user settings (putUserSettings)", notes = "Update user settings for authorized user. Only specified json elements will be updated." + "Example: you have such settings: {A:5, B:{C:10, D:20}}. Updating it with {B:{C:10, D:30}} will result in" + "{A:5, B:{C:10, D:30}}. The same could be achieved by putting {B.D:30}") @@ -518,15 +533,15 @@ public class UserController extends BaseController { return userSettings == null ? JacksonUtil.newObjectNode() : userSettings.getSettings(); } - @ApiOperation(value = "Delete user settings (deleteUserSettings)", + @ApiOperation(value = "Delete user settings by type (deleteUserSettingsByType)", notes = "Delete user settings by specifying list of json element xpaths. \n " + "Example: to delete B and C element in { \"A\": {\"B\": 5}, \"C\": 15} send A.B,C in jsonPaths request parameter") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @DeleteMapping(value = "/user/settings/{type}/{paths}") - public void deleteUserSettings(@Parameter(description = PATHS) - @PathVariable(PATHS) String paths, - @Parameter(description = "Settings type, case insensitive, one of: \"general\", \"quick_links\", \"doc_links\" or \"dashboards\".") - @PathVariable("type") String strType) throws ThingsboardException { + public void deleteUserSettingsByType(@Parameter(description = PATHS) + @PathVariable(PATHS) String paths, + @Parameter(description = "Settings type, case insensitive, one of: \"general\", \"quick_links\", \"doc_links\" or \"dashboards\".") + @PathVariable("type") String strType) throws ThingsboardException { checkParameter(USER_ID, paths); UserSettingsType type = checkEnumParameter("Settings type", strType, UserSettingsType::valueOf); checkNotReserved(strType, type); @@ -534,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 (getLastVisitedDashboards)", - 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 { @@ -543,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')") @@ -583,12 +605,10 @@ public class UserController extends BaseController { userService.removeMobileSession(user.getTenantId(), mobileToken); } - @ApiOperation(value = "Get Users By Ids (getUsersByIds)", - notes = "Requested users must be owned by tenant or assigned to customer which user is performing the request. ") + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/users", params = {"userIds"}) - public List getUsersByIds( - @Parameter(description = "A list of user ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + public List getUsersByIdsV1( @RequestParam("userIds") Set userUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List userIds = new ArrayList<>(); @@ -599,6 +619,16 @@ public class UserController extends BaseController { return filterUsersByReadPermission(users); } + @ApiOperation(value = "Get Users By Ids (getUsersByIds)", + notes = "Requested users must be owned by tenant or assigned to customer which user is performing the request. ") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/users/list") + public List getUsersByIds( + @Parameter(description = "A list of user ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("userIds") Set userUUIDs) throws ThingsboardException { + return getUsersByIdsV1(userUUIDs); + } + private List filterUsersByReadPermission(List users) { return users.stream().filter(user -> { try { 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 a519d92e3a..a89d870bf0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -209,8 +210,7 @@ public class WidgetTypeController extends AutoCommitController { } } - @ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypesByBundleAlias) (Deprecated)", - notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/widgetTypes", params = {"isSystem", "bundleAlias"}) @Deprecated @@ -229,19 +229,27 @@ public class WidgetTypeController extends AutoCommitController { return checkNotNull(widgetTypeService.findWidgetTypesByWidgetsBundleId(getTenantId(), widgetsBundle.getId())); } - @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) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetTypes", params = {"widgetsBundleId"}) - public List getBundleWidgetTypes( + public List getBundleWidgetTypesV1( @Parameter(description = "Widget Bundle Id", required = true) @RequestParam("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { WidgetsBundleId widgetsBundleId = new WidgetsBundleId(toUUID(strWidgetsBundleId)); return checkNotNull(widgetTypeService.findWidgetTypesByWidgetsBundleId(getTenantId(), widgetsBundleId)); } - @ApiOperation(value = "Get all Widget types details for specified Bundle (getBundleWidgetTypesDetailsByBundleAlias) (Deprecated)", - notes = "Returns an array of Widget Type Details objects that belong to specified Widget Bundle." + WIDGET_TYPE_DETAILS_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @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 = "/widgetsBundle/{widgetsBundleId}/widgetTypes") + public List getBundleWidgetTypes( + @Parameter(description = "Widget Bundle Id", required = true) + @PathVariable("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { + return getBundleWidgetTypesV1(strWidgetsBundleId); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/widgetTypesDetails", params = {"isSystem", "bundleAlias"}) @Deprecated @@ -284,7 +292,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get all Widget type fqns for specified Bundle (getBundleWidgetTypeFqns)", notes = "Returns an array of Widget Type fqns that belong to specified Widget Bundle." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @GetMapping(value = "/widgetTypeFqns", params = {"widgetsBundleId"}) + @GetMapping(value = "/widgetTypeFqns") public List getBundleWidgetTypeFqns( @Parameter(description = "Widget Bundle Id", required = true) @RequestParam("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { @@ -292,8 +300,7 @@ public class WidgetTypeController extends AutoCommitController { return checkNotNull(widgetTypeService.findWidgetFqnsByWidgetsBundleId(getTenantId(), widgetsBundleId)); } - @ApiOperation(value = "Get Widget Type Info objects (getBundleWidgetTypesInfosByBundleAlias) (Deprecated)", - notes = "Get the Widget Type Info objects based on the provided parameters. " + WIDGET_TYPE_INFO_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetTypesInfos", params = {"isSystem", "bundleAlias"}) @Deprecated @@ -344,8 +351,7 @@ public class WidgetTypeController extends AutoCommitController { widgetTypeDeprecatedFilter, widgetTypes, pageLink)); } - @ApiOperation(value = "Get Widget Type (getWidgetTypeByBundleAliasAndTypeAlias) (Deprecated)", - notes = "Get the Widget Type based on the provided parameters. " + WIDGET_TYPE_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetType", params = {"isSystem", "bundleAlias", "alias"}) @Deprecated diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java index c2d612f014..af087c5fd2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; 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.GetMapping; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -222,11 +222,10 @@ public class WidgetsBundleController extends BaseController { } } - @ApiOperation(value = "Get all Widget Bundles (getWidgetsBundles)", - notes = "Returns an array of Widget Bundle objects that are available for current user." + WIDGET_BUNDLE_DESCRIPTION + " " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetsBundles") - public List getWidgetsBundles() throws ThingsboardException { + public List getWidgetsBundlesV1() throws ThingsboardException { if (Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { return checkNotNull(widgetsBundleService.findSystemWidgetsBundles(getTenantId())); } else { @@ -235,13 +234,18 @@ public class WidgetsBundleController extends BaseController { } } - @ApiOperation(value = "Get Widgets Bundles By Ids (getWidgetsBundlesByIds)", - notes = "Requested widgets bundles must be system level or owned by tenant of the user which is performing the request. " + - NEW_LINE) + @ApiOperation(value = "Get all Widget Bundles (getAllWidgetsBundles)", + notes = "Returns an array of Widget Bundle objects that are available for current user." + WIDGET_BUNDLE_DESCRIPTION + " " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/widgetsBundles/all") + public List getAllWidgetsBundles() throws ThingsboardException { + return getWidgetsBundlesV1(); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetsBundles", params = {"widgetsBundleIds"}) public List getWidgetsBundlesByIds( - @Parameter(description = "A list of widgets bundle ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("widgetsBundleIds") Set widgetsBundleUUIDs) throws ThingsboardException { List widgetsBundleIds = new ArrayList<>(); for (UUID widgetsBundleUUID : widgetsBundleUUIDs) { @@ -250,4 +254,15 @@ public class WidgetsBundleController extends BaseController { return widgetsBundleService.findSystemOrTenantWidgetsBundlesByIds(getTenantId(), widgetsBundleIds); } + @ApiOperation(value = "Get Widgets Bundles By Ids (getWidgetsBundlesList)", + notes = "Requested widgets bundles must be system level or owned by tenant of the user which is performing the request. " + + NEW_LINE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/widgetsBundles/list", params = {"widgetsBundleIds"}) + public List getWidgetsBundlesList( + @Parameter(description = "A list of widgets bundle ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("widgetsBundleIds") Set widgetsBundleUUIDs) throws ThingsboardException { + return getWidgetsBundlesByIds(widgetsBundleUUIDs); + } + } 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 73315be73f..133584201c 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 @@ -115,7 +115,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; @@ -611,7 +611,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) { @@ -655,7 +655,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/action/EntityActionService.java b/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java index 774396a704..3b72ac662f 100644 --- a/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java +++ b/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.action; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -235,7 +236,7 @@ public class EntityActionService { } } - public void logEntityAction(User user, I entityId, E entity, CustomerId customerId, + public void logEntityAction(User user, @NotNull I entityId, E entity, CustomerId customerId, ActionType actionType, Exception e, Object... additionalInfo) { if (customerId == null || customerId.isNullUid()) { customerId = user.getCustomerId(); 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 1719c95f7a..8aa2c6d1c9 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/edge/EdgeContextComponent.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java index a3b9ba2810..c99261b249 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java @@ -25,6 +25,7 @@ import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.dao.pat.ApiKeyService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetProfileService; @@ -61,6 +62,7 @@ import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings; import org.thingsboard.server.service.edge.rpc.EdgeRpcService; import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; import org.thingsboard.server.service.edge.rpc.processor.ai.AiModelProcessor; +import org.thingsboard.server.service.edge.rpc.processor.apikey.ApiKeyProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.AlarmProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.comment.AlarmCommentProcessor; import org.thingsboard.server.service.edge.rpc.processor.asset.AssetEdgeProcessor; @@ -78,6 +80,7 @@ import org.thingsboard.server.service.edge.rpc.processor.telemetry.TelemetryEdge import org.thingsboard.server.service.edge.rpc.processor.user.UserProcessor; import org.thingsboard.server.service.edge.rpc.sync.EdgeRequestsService; import org.thingsboard.server.service.executors.GrpcCallbackExecutorService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.util.EnumMap; import java.util.List; @@ -104,6 +107,9 @@ public class EdgeContextComponent { } // services + @Autowired + private TelemetrySubscriptionService tsSubService; + @Autowired private AdminSettingsService adminSettingsService; @@ -269,6 +275,12 @@ public class EdgeContextComponent { @Autowired private AiModelProcessor aiModelProcessor; + @Autowired + private ApiKeyService apiKeyService; + + @Autowired + private ApiKeyProcessor apiKeyProcessor; + @Autowired private UserProcessor userProcessor; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java index 3003c046b9..01d2178cd1 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java @@ -41,6 +41,7 @@ import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TbResource; @@ -48,6 +49,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.ai.AiModel; +import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.asset.Asset; @@ -60,6 +62,7 @@ import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -97,6 +100,7 @@ import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.common.transport.util.JsonUtils; import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -193,6 +197,10 @@ public class EdgeMsgConstructorUtils { ) ); + private static void resetVersion(HasVersion entity) { + entity.setVersion(null); + } + public static AlarmUpdateMsg constructAlarmUpdatedMsg(UpdateMsgType msgType, Alarm alarm) { return AlarmUpdateMsg.newBuilder().setMsgType(msgType) .setEntity(JacksonUtil.toString(alarm)) @@ -205,6 +213,7 @@ public class EdgeMsgConstructorUtils { } public static AssetUpdateMsg constructAssetUpdatedMsg(UpdateMsgType msgType, Asset asset) { + resetVersion(asset); return AssetUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(asset)) .setIdMSB(asset.getUuidId().getMostSignificantBits()) .setIdLSB(asset.getUuidId().getLeastSignificantBits()).build(); @@ -218,6 +227,7 @@ public class EdgeMsgConstructorUtils { } public static AssetProfileUpdateMsg constructAssetProfileUpdatedMsg(UpdateMsgType msgType, AssetProfile assetProfile) { + resetVersion(assetProfile); return AssetProfileUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(assetProfile)) .setIdMSB(assetProfile.getId().getId().getMostSignificantBits()) .setIdLSB(assetProfile.getId().getId().getLeastSignificantBits()).build(); @@ -231,6 +241,7 @@ public class EdgeMsgConstructorUtils { } public static CustomerUpdateMsg constructCustomerUpdatedMsg(UpdateMsgType msgType, Customer customer) { + resetVersion(customer); return CustomerUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(customer)) .setIdMSB(customer.getId().getId().getMostSignificantBits()) .setIdLSB(customer.getId().getId().getLeastSignificantBits()).build(); @@ -244,6 +255,7 @@ public class EdgeMsgConstructorUtils { } public static DashboardUpdateMsg constructDashboardUpdatedMsg(UpdateMsgType msgType, Dashboard dashboard) { + resetVersion(dashboard); return DashboardUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(dashboard)) .setIdMSB(dashboard.getId().getId().getMostSignificantBits()) .setIdLSB(dashboard.getId().getId().getLeastSignificantBits()).build(); @@ -257,6 +269,7 @@ public class EdgeMsgConstructorUtils { } public static DeviceUpdateMsg constructDeviceUpdatedMsg(UpdateMsgType msgType, Device device) { + resetVersion(device); return DeviceUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(device)) .setIdMSB(device.getId().getId().getMostSignificantBits()) .setIdLSB(device.getId().getId().getLeastSignificantBits()).build(); @@ -270,10 +283,12 @@ public class EdgeMsgConstructorUtils { } public static DeviceCredentialsUpdateMsg constructDeviceCredentialsUpdatedMsg(DeviceCredentials deviceCredentials) { + resetVersion(deviceCredentials); return DeviceCredentialsUpdateMsg.newBuilder().setEntity(JacksonUtil.toString(deviceCredentials)).build(); } public static DeviceProfileUpdateMsg constructDeviceProfileUpdatedMsg(UpdateMsgType msgType, DeviceProfile deviceProfile, EdgeVersion edgeVersion) { + resetVersion(deviceProfile); String entity = getEntityAndFixLwm2mBootstrapShortServerId(deviceProfile, edgeVersion); return DeviceProfileUpdateMsg.newBuilder().setMsgType(msgType).setEntity(entity) .setIdMSB(deviceProfile.getId().getId().getMostSignificantBits()) @@ -387,6 +402,7 @@ public class EdgeMsgConstructorUtils { } public static EntityViewUpdateMsg constructEntityViewUpdatedMsg(UpdateMsgType msgType, EntityView entityView) { + resetVersion(entityView); return EntityViewUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(entityView)) .setIdMSB(entityView.getId().getId().getMostSignificantBits()) .setIdLSB(entityView.getId().getId().getLeastSignificantBits()).build(); @@ -486,6 +502,7 @@ public class EdgeMsgConstructorUtils { } public static RelationUpdateMsg constructRelationUpdatedMsg(UpdateMsgType msgType, EntityRelation entityRelation) { + resetVersion(entityRelation); return RelationUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(entityRelation)).build(); } @@ -503,6 +520,7 @@ public class EdgeMsgConstructorUtils { } public static RuleChainUpdateMsg constructRuleChainUpdatedMsg(UpdateMsgType msgType, RuleChain ruleChain, boolean isRoot) { + resetVersion(ruleChain); boolean isTemplateRoot = ruleChain.isRoot(); ruleChain.setRoot(isRoot); RuleChainUpdateMsg result = RuleChainUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(ruleChain)) @@ -520,6 +538,7 @@ public class EdgeMsgConstructorUtils { } public static RuleChainMetadataUpdateMsg constructRuleChainMetadataUpdatedMsg(UpdateMsgType msgType, RuleChainMetaData ruleChainMetaData, EdgeVersion edgeVersion) { + resetVersion(ruleChainMetaData); String metaData = sanitizeMetadataForLegacyEdgeVersion(ruleChainMetaData, edgeVersion); return RuleChainMetadataUpdateMsg.newBuilder() @@ -640,6 +659,7 @@ public class EdgeMsgConstructorUtils { } public static TenantUpdateMsg constructTenantUpdateMsg(UpdateMsgType msgType, Tenant tenant) { + resetVersion(tenant); return TenantUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(tenant)).build(); } @@ -648,6 +668,7 @@ public class EdgeMsgConstructorUtils { } public static UserUpdateMsg constructUserUpdatedMsg(UpdateMsgType msgType, User user) { + resetVersion(user); return UserUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(user)) .setIdMSB(user.getId().getId().getMostSignificantBits()) .setIdLSB(user.getId().getId().getLeastSignificantBits()).build(); @@ -665,6 +686,7 @@ public class EdgeMsgConstructorUtils { } public static WidgetsBundleUpdateMsg constructWidgetsBundleUpdateMsg(UpdateMsgType msgType, WidgetsBundle widgetsBundle, List widgets) { + resetVersion(widgetsBundle); return WidgetsBundleUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(widgetsBundle)) .setWidgets(JacksonUtil.toString(widgets)) .setIdMSB(widgetsBundle.getId().getId().getMostSignificantBits()) @@ -680,6 +702,7 @@ public class EdgeMsgConstructorUtils { } public static WidgetTypeUpdateMsg constructWidgetTypeUpdateMsg(UpdateMsgType msgType, WidgetTypeDetails widgetTypeDetails) { + resetVersion(widgetTypeDetails); return WidgetTypeUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(widgetTypeDetails)) .setIdMSB(widgetTypeDetails.getId().getId().getMostSignificantBits()) .setIdLSB(widgetTypeDetails.getId().getId().getLeastSignificantBits()).build(); @@ -694,6 +717,7 @@ public class EdgeMsgConstructorUtils { } public static CalculatedFieldUpdateMsg constructCalculatedFieldUpdatedMsg(UpdateMsgType msgType, CalculatedField calculatedField) { + resetVersion(calculatedField); return CalculatedFieldUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(calculatedField)) .setIdMSB(calculatedField.getId().getId().getMostSignificantBits()) .setIdLSB(calculatedField.getId().getId().getLeastSignificantBits()).build(); @@ -707,6 +731,7 @@ public class EdgeMsgConstructorUtils { } public static AiModelUpdateMsg constructAiModelUpdatedMsg(UpdateMsgType msgType, AiModel aiModel) { + resetVersion(aiModel); return AiModelUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(aiModel)) .setIdMSB(aiModel.getId().getId().getMostSignificantBits()) .setIdLSB(aiModel.getId().getId().getLeastSignificantBits()).build(); @@ -719,6 +744,19 @@ public class EdgeMsgConstructorUtils { .setIdLSB(aiModelId.getId().getLeastSignificantBits()).build(); } + public static ApiKeyUpdateMsg constructApiKeyUpdatedMsg(UpdateMsgType msgType, ApiKey apiKey) { + return ApiKeyUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(apiKey)) + .setIdMSB(apiKey.getId().getId().getMostSignificantBits()) + .setIdLSB(apiKey.getId().getId().getLeastSignificantBits()).build(); + } + + public static ApiKeyUpdateMsg constructApiKeyDeleteMsg(ApiKeyId apiKeyId) { + return ApiKeyUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(apiKeyId.getId().getMostSignificantBits()) + .setIdLSB(apiKeyId.getId().getLeastSignificantBits()).build(); + } + public static List mergeAndFilterDownlinkDuplicates(List edgeEvents) { try { edgeEvents = removeDownlinkDuplicates(edgeEvents); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java new file mode 100644 index 0000000000..7d0f14c24e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java @@ -0,0 +1,43 @@ +/** + * 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.edge.rpc; + +import com.google.common.util.concurrent.FutureCallback; +import jakarta.annotation.Nullable; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; + +@Slf4j +@AllArgsConstructor +public class AttributeSaveCallback implements FutureCallback { + + private final TenantId tenantId; + private final EdgeId edgeId; + private final String key; + private final Object value; + + @Override + public void onSuccess(@Nullable Void result) { + log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 4e7c0dad0c..072a4878d5 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -140,9 +140,6 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i @Lazy private EdgeContextComponent ctx; - @Autowired - private TelemetrySubscriptionService tsSubService; - @Autowired private TbClusterService clusterService; @@ -583,14 +580,14 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void save(TenantId tenantId, EdgeId edgeId, String key, long value) { log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value); if (persistToTelemetry) { - tsSubService.saveTimeseries(TimeseriesSaveRequest.builder() + ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder() .tenantId(tenantId) .entityId(edgeId) .entry(new LongDataEntry(key, value)) .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) .build()); } else { - tsSubService.saveAttributes(AttributesSaveRequest.builder() + ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder() .tenantId(tenantId) .entityId(edgeId) .scope(AttributeScope.SERVER_SCOPE) @@ -603,14 +600,14 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) { log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value); if (persistToTelemetry) { - tsSubService.saveTimeseries(TimeseriesSaveRequest.builder() + ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder() .tenantId(tenantId) .entityId(edgeId) .entry(new BooleanDataEntry(key, value)) .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) .build()); } else { - tsSubService.saveAttributes(AttributesSaveRequest.builder() + ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder() .tenantId(tenantId) .entityId(edgeId) .scope(AttributeScope.SERVER_SCOPE) @@ -620,32 +617,6 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } - private static class AttributeSaveCallback implements FutureCallback { - - private final TenantId tenantId; - private final EdgeId edgeId; - private final String key; - private final Object value; - - AttributeSaveCallback(TenantId tenantId, EdgeId edgeId, String key, Object value) { - this.tenantId = tenantId; - this.edgeId = edgeId; - this.key = key; - this.value = value; - } - - @Override - public void onSuccess(@Nullable Void result) { - log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value); - } - - @Override - public void onFailure(Throwable t) { - log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t); - } - - } - private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) { try { EdgeId edgeId = edge.getId(); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index 2b026f207f..de4a92ae8c 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -25,6 +25,7 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; import org.springframework.data.util.Pair; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EdgeUtils; @@ -37,6 +38,7 @@ import org.thingsboard.server.common.data.id.TenantId; 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.BooleanDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.limit.LimitedApi; @@ -47,6 +49,7 @@ import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; import org.thingsboard.server.dao.edge.stats.EdgeStatsKey; import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -195,7 +198,7 @@ public abstract class EdgeGrpcSession implements Closeable { } startSyncProcess(fullSync); } else { - syncInProgress = false; + updateSyncInProgress(false); } } if (requestMsg.getMsgType().equals(RequestMsgType.UPLINK_RPC_MESSAGE)) { @@ -241,6 +244,11 @@ public abstract class EdgeGrpcSession implements Closeable { log.debug("[{}] onConfigurationUpdate [{}]", sessionId, edge); this.tenantId = edge.getTenantId(); this.edge = edge; + if (!this.edge.getCustomerId().equals(edge.getCustomerId())) { + // do not send edge configuration message on customer update + // message send by separate flow from assign_to or unassing_from customer + return; + } EdgeUpdateMsg edgeConfig = EdgeUpdateMsg.newBuilder() .setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge)).build(); ResponseMsg edgeConfigMsg = ResponseMsg.newBuilder() @@ -252,7 +260,7 @@ public abstract class EdgeGrpcSession implements Closeable { public void startSyncProcess(boolean fullSync) { if (!syncInProgress) { log.info("[{}][{}][{}] Staring edge sync process", tenantId, edge.getId(), sessionId); - syncInProgress = true; + updateSyncInProgress(true); interruptGeneralProcessingOnSync(); doSync(new EdgeSyncCursor(ctx, edge, fullSync)); } else { @@ -398,6 +406,18 @@ public abstract class EdgeGrpcSession implements Closeable { ctx.getAttributesService().save(tenantId, edge.getId(), AttributeScope.SERVER_SCOPE, attributeKvEntry); } + private void updateSyncInProgress(Boolean value) { + this.syncInProgress = value; + + ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(edge.getId()) + .scope(AttributeScope.SERVER_SCOPE) + .entry(new BooleanDataEntry(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value)) + .callback(new AttributeSaveCallback(tenantId, edge.getId(), DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value)) + .build()); + } + private void interruptGeneralProcessingOnSync() { log.debug("[{}][{}][{}] Sync process started. General processing interrupted!", tenantId, edge.getId(), sessionId); stopCurrentSendDownlinkMsgsTask(true); @@ -766,7 +786,7 @@ public abstract class EdgeGrpcSession implements Closeable { } private void markSyncCompletedSendEdgeEventUpdate() { - syncInProgress = false; + updateSyncInProgress(false); ctx.getClusterService().onEdgeEventUpdate(new EdgeEventUpdateMsg(edge.getTenantId(), edge.getId())); } @@ -969,6 +989,16 @@ public abstract class EdgeGrpcSession implements Closeable { } } } + if (uplinkMsg.getApiKeyUpdateMsgCount() > 0) { + for (ApiKeyUpdateMsg apiKeyUpdateMsg : uplinkMsg.getApiKeyUpdateMsgList()) { + sequenceDependencyLock.lock(); + try { + result.add(ctx.getApiKeyProcessor().processApiKeyMsgFromEdge(edge.getTenantId(), edge, apiKeyUpdateMsg)); + } finally { + sequenceDependencyLock.unlock(); + } + } + } } catch (Exception e) { String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg); log.trace("[{}][{}] Can't process uplink msg [{}]", tenantId, edge.getId(), uplinkMsg, e); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java index cc77a41f6a..8455d86833 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java @@ -146,7 +146,7 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor { UPDATED_COMMENT, DELETED -> true; default -> switch (type) { case ALARM, ALARM_COMMENT, RULE_CHAIN, RULE_CHAIN_METADATA, USER, CUSTOMER, TENANT, TENANT_PROFILE, - WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, AI_MODEL, NOTIFICATION_TEMPLATE, + WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, AI_MODEL, API_KEY, NOTIFICATION_TEMPLATE, NOTIFICATION_TARGET, NOTIFICATION_RULE -> true; default -> false; }; @@ -412,6 +412,9 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor { } protected boolean isSaveRequired(HasVersion current, HasVersion updated) { + if (current != null) { + current.setVersion(null); + } updated.setVersion(null); return !updated.equals(current); } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyEdgeProcessor.java new file mode 100644 index 0000000000..960c0dd695 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyEdgeProcessor.java @@ -0,0 +1,105 @@ +/** + * 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.edge.rpc.processor.apikey; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.exception.DataValidationException; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; + +import java.util.UUID; + +@Slf4j +@Component +@TbCoreComponent +public class ApiKeyEdgeProcessor extends BaseApiKeyProcessor implements ApiKeyProcessor { + + @Override + public ListenableFuture processApiKeyMsgFromEdge(TenantId tenantId, Edge edge, ApiKeyUpdateMsg apiKeyUpdateMsg) { + ApiKeyId apiKeyId = new ApiKeyId(new UUID(apiKeyUpdateMsg.getIdMSB(), apiKeyUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + return switch (apiKeyUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE, ENTITY_UPDATED_RPC_MESSAGE -> { + boolean created = saveOrUpdateApiKey(tenantId, apiKeyId, apiKeyUpdateMsg); + if (created) { + ApiKey apiKey = edgeCtx.getApiKeyService().findApiKeyById(tenantId, apiKeyId); + if (apiKey != null) { + pushEntityEventToRuleEngine(tenantId, edge, apiKey, TbMsgType.ENTITY_CREATED); + } + } + yield Futures.immediateFuture(null); + } + case ENTITY_DELETED_RPC_MESSAGE -> { + deleteApiKey(tenantId, edge, apiKeyId); + yield Futures.immediateFuture(null); + } + default -> handleUnsupportedMsgType(apiKeyUpdateMsg.getMsgType()); + }; + } catch (DataValidationException e) { + return Futures.immediateFailedFuture(e); + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + } + + @Override + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { + ApiKeyId apiKeyId = new ApiKeyId(edgeEvent.getEntityId()); + switch (edgeEvent.getAction()) { + case ADDED, UPDATED -> { + ApiKey apiKey = edgeCtx.getApiKeyService().findApiKeyById(edgeEvent.getTenantId(), apiKeyId); + if (apiKey != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + ApiKeyUpdateMsg apiKeyUpdateMsg = EdgeMsgConstructorUtils.constructApiKeyUpdatedMsg(msgType, apiKey); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addApiKeyUpdateMsg(apiKeyUpdateMsg) + .build(); + } + } + case DELETED -> { + ApiKeyUpdateMsg apiKeyUpdateMsg = EdgeMsgConstructorUtils.constructApiKeyDeleteMsg(apiKeyId); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addApiKeyUpdateMsg(apiKeyUpdateMsg) + .build(); + } + } + return null; + } + + @Override + public EdgeEventType getEdgeEventType() { + return EdgeEventType.API_KEY; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyProcessor.java new file mode 100644 index 0000000000..f50594c9b7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyProcessor.java @@ -0,0 +1,28 @@ +/** + * 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.edge.rpc.processor.apikey; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; + +public interface ApiKeyProcessor extends EdgeProcessor { + + ListenableFuture processApiKeyMsgFromEdge(TenantId tenantId, Edge edge, ApiKeyUpdateMsg apiKeyUpdateMsg); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/BaseApiKeyProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/BaseApiKeyProcessor.java new file mode 100644 index 0000000000..7d110c3670 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/BaseApiKeyProcessor.java @@ -0,0 +1,63 @@ +/** + * 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.edge.rpc.processor.apikey; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +@Slf4j +public abstract class BaseApiKeyProcessor extends BaseEdgeProcessor { + + protected boolean saveOrUpdateApiKey(TenantId tenantId, ApiKeyId apiKeyId, ApiKeyUpdateMsg apiKeyUpdateMsg) { + boolean isCreated = false; + try { + ApiKey apiKey = JacksonUtil.fromString(apiKeyUpdateMsg.getEntity(), ApiKey.class, true); + if (apiKey == null) { + throw new RuntimeException("[{" + tenantId + "}] apiKeyUpdateMsg {" + apiKeyUpdateMsg + " } cannot be converted to apiKey"); + } + + ApiKey existingApiKey = edgeCtx.getApiKeyService().findApiKeyById(tenantId, apiKeyId); + if (existingApiKey == null) { + apiKey.setCreatedTime(Uuids.unixTimestamp(apiKeyId.getId())); + isCreated = true; + } + + apiKey.setId(apiKeyId); + edgeCtx.getApiKeyService().saveApiKey(tenantId, apiKey, apiKey.getValue(), false); + } catch (Exception e) { + log.error("[{}] Failed to process apiKey update msg [{}]", tenantId, apiKeyUpdateMsg, e); + throw e; + } + return isCreated; + } + + protected void deleteApiKey(TenantId tenantId, Edge edge, ApiKeyId apiKeyId) { + ApiKey apiKey = edgeCtx.getApiKeyService().findApiKeyById(tenantId, apiKeyId); + if (apiKey != null) { + edgeCtx.getApiKeyService().deleteApiKey(tenantId, apiKey, false); + pushEntityEventToRuleEngine(tenantId, edge, apiKey, TbMsgType.ENTITY_DELETED); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index 027b1225c4..03fc29ae3c 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -23,6 +23,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; @@ -34,6 +35,7 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.exception.DataValidationException; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; @@ -42,6 +44,7 @@ import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; +import java.util.List; import java.util.UUID; @Slf4j @@ -135,6 +138,10 @@ public class UserEdgeProcessor extends BaseUserProcessor implements UserProcesso if (userCredentialsByUserId != null) { builder.addUserCredentialsUpdateMsg(EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentialsByUserId)); } + List apiKeys = edgeCtx.getApiKeyService().findApiKeysByUserId(edgeEvent.getTenantId(), userId); + for (ApiKey apiKey : apiKeys) { + builder.addApiKeyUpdateMsg(EdgeMsgConstructorUtils.constructApiKeyUpdatedMsg(msgType, apiKey)); + } return builder.build(); } } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java index 279ed918c8..efa000e521 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java @@ -97,6 +97,10 @@ public abstract class AbstractTbEntityService { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } + protected I getOrEmptyId(I entityId, EntityType entityType) { + return entityId == null ? emptyId(entityType) : entityId; + } + protected ListenableFuture autoCommit(User user, EntityId entityId) { if (vcService != null) { return vcService.autoCommit(user, entityId); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbLogEntityActionService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbLogEntityActionService.java index 05ec142e9d..685e447887 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbLogEntityActionService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbLogEntityActionService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -60,7 +61,7 @@ public class DefaultTbLogEntityActionService implements TbLogEntityActionService } @Override - public void logEntityAction(TenantId tenantId, I entityId, E entity, + public void logEntityAction(TenantId tenantId, @NotNull I entityId, E entity, CustomerId customerId, ActionType actionType, User user, Exception e, Object... additionalInfo) { if (user != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index 5f4a6d29ee..c3d275883e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -45,6 +45,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobStatus; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; @@ -360,10 +361,12 @@ public class EntityStateSourcingListener { jobManager.onJobUpdate(job); ComponentLifecycleEvent event; - if (job.getResult().getCancellationTs() > 0) { + if (job.getStatus() == JobStatus.CANCELLED) { event = ComponentLifecycleEvent.STOPPED; - } else if (job.getResult().getGeneralError() != null) { + } else if (job.getStatus() == JobStatus.FAILED) { event = ComponentLifecycleEvent.FAILED; + } else if (job.getStatus() == JobStatus.COMPLETED) { + event = ComponentLifecycleEvent.UPDATED; } else { return; } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/TbLogEntityActionService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/TbLogEntityActionService.java index 712bca5b49..f574d4df23 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/TbLogEntityActionService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/TbLogEntityActionService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy; +import jakarta.validation.constraints.NotNull; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; @@ -37,7 +38,7 @@ public interface TbLogEntityActionService { void logEntityAction(TenantId tenantId, I entityId, E entity, CustomerId customerId, ActionType actionType, User user, Object... additionalInfo); - void logEntityAction(TenantId tenantId, I entityId, E entity, CustomerId customerId, + void logEntityAction(TenantId tenantId, @NotNull I entityId, E entity, CustomerId customerId, ActionType actionType, User user, Exception e, Object... additionalInfo); 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/entitiy/tenant/profile/DefaultTbTenantProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java index fce44972d8..25dadbe316 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java @@ -15,21 +15,28 @@ */ package org.thingsboard.server.service.entitiy.tenant.profile; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; import org.thingsboard.server.service.entitiy.queue.TbQueueService; import java.util.List; +import static org.thingsboard.server.common.data.EntityType.TENANT_PROFILE; + @Slf4j @Service @TbCoreComponent @@ -41,18 +48,62 @@ public class DefaultTbTenantProfileService extends AbstractTbEntityService imple private final TbTenantProfileCache tenantProfileCache; @Override - public TenantProfile save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile) throws ThingsboardException { - TenantProfile savedTenantProfile = checkNotNull(tenantProfileService.saveTenantProfile(tenantId, tenantProfile)); - tenantProfileCache.put(savedTenantProfile); + public TenantProfile + save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile, User user) throws ThingsboardException { + ActionType actionType = tenantProfile.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + try { + TenantProfile savedTenantProfile = checkNotNull(tenantProfileService.saveTenantProfile(tenantId, tenantProfile)); + tenantProfileCache.put(savedTenantProfile); + logEntityActionService.logEntityAction(tenantId, savedTenantProfile.getId(), savedTenantProfile, null, + actionType, user); + + List tenantIds = tenantService.findTenantIdsByTenantProfileId(savedTenantProfile.getId()); + tbQueueService.updateQueuesByTenants(tenantIds, savedTenantProfile, oldTenantProfile); - List tenantIds = tenantService.findTenantIdsByTenantProfileId(savedTenantProfile.getId()); - tbQueueService.updateQueuesByTenants(tenantIds, savedTenantProfile, oldTenantProfile); + return savedTenantProfile; + } catch (ThingsboardException e) { + log.debug("Failed to save tenant profile because ThingsboardException [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, getOrEmptyId(tenantProfile.getId(), TENANT_PROFILE), tenantProfile, actionType, user, e); + throw e; + } catch (DataValidationException e) { + log.debug("Failed to save tenant profile because data validation [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, getOrEmptyId(tenantProfile.getId(), TENANT_PROFILE), tenantProfile, actionType, user, e); + throw new ThingsboardException(e.getMessage(), e, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } catch (Exception e) { + log.debug("Failed to save tenant profile because Exception [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, getOrEmptyId(tenantProfile.getId(), TENANT_PROFILE), tenantProfile, actionType, user, e); + throw new ThingsboardException(e.getMessage(), e, ThingsboardErrorCode.GENERAL); + } - return savedTenantProfile; } @Override - public void delete(TenantId tenantId, TenantProfile tenantProfile) throws ThingsboardException { - tenantProfileService.deleteTenantProfile(tenantId, tenantProfile.getId()); + public void delete(TenantId tenantId, @NotNull TenantProfile tenantProfile, User user) throws ThingsboardException { + ActionType actionType = ActionType.DELETED; + try { + tenantProfileService.deleteTenantProfile(tenantId, tenantProfile.getId()); + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), tenantProfile, null, actionType, user); + } catch (Exception e) { + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), tenantProfile, null, actionType, user, e); + throw e; + } + } + + @Override + public TenantProfile setDefaultTenantProfile(TenantId tenantId, @NotNull TenantProfile tenantProfile, User user) throws ThingsboardException { + ActionType actionType = ActionType.UPDATED; + try { + TenantProfile savedTenantProfile = tenantProfileService.setDefaultTenantProfile(tenantId, tenantProfile.getId()); + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), savedTenantProfile, null, actionType, user); + return savedTenantProfile; + } catch (DataValidationException e) { + log.debug("Failed to set default tenant profile due to data validation [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), tenantProfile, actionType, user, e); + throw new ThingsboardException(e.getMessage(), e, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } catch (Exception e) { + log.debug("Failed to set default tenant profile [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), tenantProfile, actionType, user, e); + throw new ThingsboardException(e.getMessage(), e, ThingsboardErrorCode.GENERAL); + } } } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java index 3ca8f0570b..d15e9c84c8 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java @@ -15,13 +15,17 @@ */ package org.thingsboard.server.service.entitiy.tenant.profile; +import jakarta.validation.constraints.NotNull; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.User; public interface TbTenantProfileService { - TenantProfile save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile) throws ThingsboardException; + TenantProfile save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile, User user) throws ThingsboardException; - void delete(TenantId tenantId, TenantProfile tenantProfile) throws ThingsboardException; + void delete(TenantId tenantId, @NotNull TenantProfile tenantProfile, User user) throws ThingsboardException; + + TenantProfile setDefaultTenantProfile(TenantId tenantId, @NotNull TenantProfile tenantProfile, User user) throws ThingsboardException; } 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/job/DefaultJobManager.java b/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java index f17f10bfd2..d14ba2067a 100644 --- a/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java +++ b/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java @@ -15,15 +15,20 @@ */ package org.thingsboard.server.service.job; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.Nullable; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ObjectUtils; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.rule.engine.api.JobManager; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.TenantId; @@ -33,7 +38,10 @@ import org.thingsboard.server.common.data.job.JobStatus; import org.thingsboard.server.common.data.job.JobType; import org.thingsboard.server.common.data.job.task.Task; import org.thingsboard.server.common.data.job.task.TaskResult; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; @@ -50,7 +58,10 @@ import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -65,6 +76,8 @@ public class DefaultJobManager implements JobManager { private final Map jobProcessors; private final Map>> taskProducers; private final ExecutorService executor; + private final ConcurrentHashMap finishCallbacks = new ConcurrentHashMap<>(); + private final ScheduledExecutorService cleanupScheduler; public DefaultJobManager(JobService jobService, JobStatsService jobStatsService, PartitionService partitionService, TaskProducerQueueFactory queueFactory, TasksQueueConfig queueConfig, @@ -76,6 +89,8 @@ public class DefaultJobManager implements JobManager { this.jobProcessors = jobProcessors.stream().collect(Collectors.toMap(JobProcessor::getType, Function.identity())); this.taskProducers = Arrays.stream(JobType.values()).collect(Collectors.toMap(Function.identity(), queueFactory::createTaskProducer)); this.executor = ThingsBoardExecutors.newWorkStealingPool(Math.max(4, Runtime.getRuntime().availableProcessors()), getClass()); + this.cleanupScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("job-callback-cleanup"); + this.cleanupScheduler.scheduleWithFixedDelay(this::cleanupStaleCallbacks, 1, 1, TimeUnit.HOURS); } @Override @@ -84,6 +99,25 @@ public class DefaultJobManager implements JobManager { return Futures.submit(() -> jobService.saveJob(job.getTenantId(), job), executor); } + @Override + public ListenableFuture submitJob(Job job, TbCallback finishCallback) { + ListenableFuture saveFuture = submitJob(job); + if (finishCallback != null) { + Futures.addCallback(saveFuture, new FutureCallback<>() { + @Override + public void onSuccess(Job savedJob) { + finishCallbacks.put(savedJob.getId(), finishCallback); + } + + @Override + public void onFailure(Throwable t) { + finishCallback.onFailure(t); + } + }, MoreExecutors.directExecutor()); + } + return saveFuture; + } + @Override public void onJobUpdate(Job job) { JobStatus status = job.getStatus(); @@ -109,6 +143,35 @@ public class DefaultJobManager implements JobManager { } } + @EventListener + public void onJobUpdateEvent(ComponentLifecycleMsg event) { + EntityId entityId = event.getEntityId(); + if (entityId.getEntityType() != EntityType.JOB) { + return; + } + + ComponentLifecycleEvent lifecycleEvent = event.getEvent(); + if (!lifecycleEvent.equals(ComponentLifecycleEvent.STOPPED) && + !lifecycleEvent.equals(ComponentLifecycleEvent.FAILED) && + !lifecycleEvent.equals(ComponentLifecycleEvent.UPDATED)) { + return; + } + JobId jobId = new JobId(entityId.getId()); + TbCallback callback = finishCallbacks.remove(jobId); + if (callback == null) { + return; + } + executor.execute(() -> { + try { + Job job = jobService.findJobById(event.getTenantId(), jobId); + invokeFinishCallback(job, callback); + } catch (Throwable e) { + log.error("[{}] Failed to invoke finish callback", jobId, e); + callback.onFailure(e); + } + }); + } + private void processJob(Job job) { TenantId tenantId = job.getTenantId(); JobId jobId = job.getId(); @@ -195,12 +258,41 @@ public class DefaultJobManager implements JobManager { }); } + private void invokeFinishCallback(@Nullable Job job, TbCallback callback) { + if (job == null) { + callback.onFailure(new RuntimeException("Job not found")); + } else if (job.getStatus() == JobStatus.COMPLETED) { + callback.onSuccess(); + } else { + callback.onFailure(new RuntimeException(job.getError())); + } + } + + private void cleanupStaleCallbacks() { + finishCallbacks.entrySet().removeIf(entry -> { + JobId jobId = entry.getKey(); + try { + Job job = jobService.findJobById(TenantId.SYS_TENANT_ID, jobId); + if (job == null || job.getStatus().isOneOf(JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED)) { + invokeFinishCallback(job, entry.getValue()); + return true; + } + return false; + } catch (Throwable e) { + log.error("[{}] Failed to cleanup stale callback", jobId, e); + entry.getValue().onFailure(e); + return true; + } + }); + } + private JobProcessor getJobProcessor(JobType jobType) { return jobProcessors.get(jobType); } @PreDestroy private void destroy() { + cleanupScheduler.shutdownNow(); executor.shutdownNow(); } 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/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 4d76989a92..50f0f0e75a 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -15,13 +15,15 @@ */ package org.thingsboard.server.service.query; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.KvUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; @@ -30,11 +32,17 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.DataType; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AvailableEntityKeys; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeySample; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.EntityCountQuery; @@ -59,11 +67,13 @@ import org.thingsboard.server.service.security.model.SecurityUser; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeMap; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @@ -253,7 +263,7 @@ public class DefaultEntityQueryService implements EntityQueryService { if (isAttributes) { Map> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType)); List>> futures = new ArrayList<>(typesMap.size()); - typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, entityIds, scope)))); + typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIdsAndScope(tenantId, entityIds, scope)))); attributesKeysFuture = Futures.transform(Futures.allAsList(futures), lists -> { if (CollectionUtils.isEmpty(lists)) { return Collections.emptyList(); @@ -274,4 +284,135 @@ public class DefaultEntityQueryService implements EntityQueryService { }, dbCallbackExecutor); } + @Override + public ListenableFuture findAvailableEntityKeysByQuery(SecurityUser securityUser, EntityDataQuery query, + boolean includeTimeseries, boolean includeAttributes, + Set scopes, boolean includeSamples) { + if (!includeTimeseries && !includeAttributes) { + return Futures.immediateFailedFuture( + new IllegalArgumentException("At least one of 'includeTimeseries' or 'includeAttributes' must be true")); + } + + return Futures.transformAsync(findEntityIdsByQueryAsync(securityUser, query), ids -> { + if (ids.isEmpty()) { + return immediateFuture(new AvailableEntityKeysV2( + Collections.emptySet(), + includeTimeseries ? Collections.emptyList() : null, + includeAttributes ? Collections.emptyMap() : null)); + } + + TenantId tenantId = securityUser.getTenantId(); + Set entityTypes = ids.stream().map(EntityId::getEntityType).collect(Collectors.toSet()); + + var tsFuture = includeTimeseries ? fetchTimeseriesKeys(tenantId, ids, includeSamples) : null; + + Set effectiveScopes = includeAttributes + ? resolveAttributeScopes(scopes, entityTypes) : Collections.emptySet(); + var attrFutures = effectiveScopes.stream() + .map(scope -> fetchAttributeKeys(tenantId, ids, scope, includeSamples)) + .toList(); + + return assembleResult(entityTypes, tsFuture, attrFutures); + }, dbCallbackExecutor); + } + + private ListenableFuture> findEntityIdsByQueryAsync(SecurityUser securityUser, EntityDataQuery query) { + return Futures.transform(entityService.findEntityDataByQueryAsync(securityUser.getTenantId(), securityUser.getCustomerId(), query), + page -> page.getData().stream() + .map(EntityData::getEntityId) + .toList(), + dbCallbackExecutor); + } + + private static Set resolveAttributeScopes(Set requestedScopes, Set entityTypes) { + boolean hasDevices = entityTypes.contains(EntityType.DEVICE); + Set scopes; + if (CollectionUtils.isNotEmpty(requestedScopes)) { + scopes = requestedScopes; + } else { // auto-determine scopes + scopes = hasDevices + ? Set.of(AttributeScope.SERVER_SCOPE, AttributeScope.CLIENT_SCOPE, AttributeScope.SHARED_SCOPE) + : Collections.singleton(AttributeScope.SERVER_SCOPE); + } + // Non-device entities only support SERVER_SCOPE + if (!hasDevices) { + return scopes.contains(AttributeScope.SERVER_SCOPE) + ? Collections.singleton(AttributeScope.SERVER_SCOPE) + : Collections.emptySet(); + } + return scopes; + } + + private ListenableFuture> fetchTimeseriesKeys(TenantId tenantId, List entityIds, boolean includeSamples) { + if (includeSamples) { + return Futures.transform( + timeseriesService.findLatestByEntityIdsAsync(tenantId, entityIds), + entries -> toKeyInfos(entries, true), + dbCallbackExecutor); + } + return Futures.transform( + timeseriesService.findAllKeysByEntityIdsAsync(tenantId, entityIds), + keys -> keys.stream().sorted().map(k -> new KeyInfo(k, null)).toList(), + dbCallbackExecutor); + } + + private ListenableFuture>> fetchAttributeKeys( + TenantId tenantId, List entityIds, AttributeScope scope, boolean includeSamples) { + if (includeSamples) { + return Futures.transform( + attributesService.findLatestByEntityIdsAndScopeAsync(tenantId, entityIds, scope), + entries -> Map.entry(scope, toKeyInfos(entries, true)), + dbCallbackExecutor); + } + return Futures.transform( + attributesService.findAllKeysByEntityIdsAndScopeAsync(tenantId, entityIds, scope), + keys -> Map.entry(scope, keys.stream().sorted().map(k -> new KeyInfo(k, null)).toList()), + dbCallbackExecutor); + } + + private ListenableFuture assembleResult( + Set entityTypes, + ListenableFuture> tsFuture, + List>>> attrFutures) { + var allAttrFuture = attrFutures.isEmpty() + ? immediateFuture(List.>>of()) + : Futures.allAsList(attrFutures); + + List> allFutures = new ArrayList<>(); + if (tsFuture != null) { + allFutures.add(tsFuture); + } + allFutures.add(allAttrFuture); + + var finalTsFuture = tsFuture; + return Futures.whenAllComplete(allFutures) + .call(() -> { + List tsKeys = finalTsFuture != null ? Futures.getDone(finalTsFuture) : null; + Map> attrMap = attrFutures.isEmpty() ? null : new TreeMap<>(); + if (attrMap != null) { + for (var entry : Futures.getDone(allAttrFuture)) { + attrMap.put(entry.getKey(), entry.getValue()); + } + } + return new AvailableEntityKeysV2(entityTypes, tsKeys, attrMap); + }, dbCallbackExecutor); + } + + private static List toKeyInfos(List entries, boolean includeSamples) { + return entries.stream() + .map(e -> new KeyInfo(e.getKey(), includeSamples ? toKeySample(e) : null)) + .sorted(Comparator.comparing(KeyInfo::key)) + .toList(); + } + + private static KeySample toKeySample(KvEntry entry) { + long ts = entry instanceof TsKvEntry tsKv ? tsKv.getTs() + : entry instanceof AttributeKvEntry attr ? attr.getLastUpdateTs() + : 0; + JsonNode value = entry.getDataType() == DataType.JSON + ? JacksonUtil.toJsonNode(entry.getJsonValue().get()) + : JacksonUtil.valueToTree(entry.getValue()); + return new KeySample(ts, value); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java index a90cf684a1..354f8e9278 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java @@ -23,11 +23,14 @@ import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AvailableEntityKeys; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.service.security.model.SecurityUser; +import java.util.Set; + public interface EntityQueryService { long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query); @@ -41,4 +44,8 @@ public interface EntityQueryService { ListenableFuture getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, boolean isTimeseries, boolean isAttributes, AttributeScope scope); + ListenableFuture findAvailableEntityKeysByQuery(SecurityUser securityUser, EntityDataQuery query, + boolean includeTimeseries, boolean includeAttributes, + Set scopes, boolean includeSamples); + } 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 d0f7bfa81c..df5683711a 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 6399e55e03..bf53a8b401 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/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/sync/ie/exporting/impl/BaseEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java index d6ed0754e3..586febd686 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java @@ -42,10 +42,6 @@ public abstract class BaseEntityExportService ctx, E mainEntity, D exportData) { } - protected D newExportData() { - return (D) new EntityExportData(); - } - public abstract Set getSupportedEntityTypes(); protected void replaceUuidsRecursively(EntitiesExportCtx ctx, JsonNode node, Set skippedRootFields, Pattern includedFieldsPattern) { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java index a488ad165e..a38e2e058e 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java @@ -70,7 +70,8 @@ public class DefaultEntityExportService ctx, I entityId) throws ThingsboardException { - D exportData = newExportData(); + @SuppressWarnings("unchecked") + D exportData = (D) EntityExportData.newInstance(entityId.getEntityType()); E entity = exportableEntitiesService.findEntityByTenantIdAndId(ctx.getTenantId(), entityId); if (entity == null) { @@ -78,7 +79,6 @@ public class DefaultEntityExportService(); - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java index 57beddef59..4a18a23b80 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java @@ -48,11 +48,6 @@ public class DeviceExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.DEVICE); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/OtaPackageExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/OtaPackageExportService.java index af29e3c5df..775ab87fcb 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/OtaPackageExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/OtaPackageExportService.java @@ -36,11 +36,6 @@ public class OtaPackageExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.OTA_PACKAGE); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java index 52452bcca4..ce93574103 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java @@ -61,11 +61,6 @@ public class RuleChainExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.RULE_CHAIN); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetTypeExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetTypeExportService.java index cb2df97a57..7e381b39bb 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetTypeExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetTypeExportService.java @@ -38,11 +38,6 @@ public class WidgetTypeExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.WIDGET_TYPE); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java index 3f579bbc43..3d4b80ce1c 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java @@ -45,11 +45,6 @@ public class WidgetsBundleExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.WIDGETS_BUNDLE); 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-openapi.properties b/application/src/main/resources/thingsboard-openapi.properties new file mode 100644 index 0000000000..410c4072a5 --- /dev/null +++ b/application/src/main/resources/thingsboard-openapi.properties @@ -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. +# + +# Lightweight startup for OpenAPI spec generation. +# Activated only via: --spring.profiles.active=openapi + +spring.main.banner-mode=off + +# Testcontainers PostgreSQL +database.ts.type=sql +database.ts_latest.type=sql +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.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver +spring.datasource.username=postgres +spring.datasource.password=postgres +spring.datasource.hikari.maximumPoolSize=5 +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true +spring.jpa.properties.hibernate.order_by.default_null_ordering=last +spring.jpa.show-sql=false +spring.jpa.hibernate.ddl-auto=none + +# Disable transports +transport.http.enabled=false +transport.mqtt.enabled=false +transport.coap.enabled=false +transport.lwm2m.enabled=false +transport.snmp.enabled=false +coap.server.enabled=false + +# Disable edges, integrations, EDQS +edges.enabled=false +integrations.rpc.enabled=false +service.integrations.supported=NONE +transport.gateway.dashboard.sync.enabled=false +queue.edqs.sync.enabled=false +queue.edqs.api.supported=false +usage.stats.report.enabled=false +service.type=monolith diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 2f595c35e8..454c59d7d7 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -58,6 +58,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: @@ -255,6 +263,8 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Statistics reporting interval, set to send summarized data every 10 seconds by default interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" check: @@ -455,6 +465,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. postgres: # Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE. ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}" @@ -579,6 +590,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}" @@ -1412,6 +1435,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 coap: @@ -1684,8 +1716,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 cf94940e07..e38a23d905 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/AIModelApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AIModelApiClientTest.java new file mode 100644 index 0000000000..59314378aa --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AIModelApiClientTest.java @@ -0,0 +1,168 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.AiModel; +import org.thingsboard.client.model.OpenAiChatModelConfig; +import org.thingsboard.client.model.OpenAiProviderConfig; +import org.thingsboard.client.model.PageDataAiModel; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class AIModelApiClientTest extends AbstractApiClientTest { + + private static final String AI_PREFIX = "AiTest_"; + + @Test + public void testSaveAndGetAiModel() throws Exception { + long ts = System.currentTimeMillis(); + String name = AI_PREFIX + "save_" + ts; + + AiModel model = buildAiModel(name, "gpt-4o", 0.7); + AiModel saved = client.saveAiModel(model); + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals(name, saved.getName()); + assertNotNull(saved.getConfiguration()); + + // get by id + AiModel fetched = client.getAiModelById(saved.getId().getId()); + assertNotNull(fetched); + assertEquals(name, fetched.getName()); + assertEquals(saved.getId().getId(), fetched.getId().getId()); + } + + @Test + public void testGetAiModelById() throws Exception { + long ts = System.currentTimeMillis(); + AiModel saved = createAiModel("getbyid_" + ts); + + AiModel fetched = client.getAiModelById(saved.getId().getId()); + assertNotNull(fetched); + assertEquals(saved.getName(), fetched.getName()); + assertEquals(saved.getId().getId(), fetched.getId().getId()); + } + + @Test + public void testUpdateAiModel() throws Exception { + long ts = System.currentTimeMillis(); + AiModel saved = createAiModel("update_" + ts); + + saved.setName(AI_PREFIX + "updated_" + ts); + OpenAiChatModelConfig updatedConfig = new OpenAiChatModelConfig(); + updatedConfig.setModelId("gpt-4o-mini"); + updatedConfig.setTemperature(0.3); + updatedConfig.setMaxOutputTokens(2048); + updatedConfig.setMaxRetries(50); + OpenAiProviderConfig providerConfig = new OpenAiProviderConfig(); + providerConfig.setApiKey("test-api-key"); + providerConfig.setBaseUrl("https://api.openai.com/v1"); + updatedConfig.setProviderConfig(providerConfig); + updatedConfig.setProvider("OPENAI"); + saved.setConfiguration(updatedConfig); + + AiModel updated = client.saveAiModel(saved); + assertNotNull(updated); + assertEquals(saved.getId().getId(), updated.getId().getId()); + assertEquals(AI_PREFIX + "updated_" + ts, updated.getName()); + } + + @Test + public void testDeleteAiModel() throws Exception { + long ts = System.currentTimeMillis(); + AiModel saved = createAiModel("delete_" + ts); + + UUID modelId = saved.getId().getId(); + client.getAiModelById(modelId); + + Boolean deleted = client.deleteAiModelById(modelId); + assertTrue(deleted); + + assertReturns404(() -> client.getAiModelById(modelId)); + } + + @Test + public void testGetAiModels() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 3; i++) { + createAiModel("list_" + ts + "_" + i); + } + + PageDataAiModel page = client.getAiModels(100, 0, AI_PREFIX + "list_" + ts, null, null); + assertNotNull(page); + assertEquals(3, page.getTotalElements().intValue()); + for (AiModel m : page.getData()) { + assertTrue(m.getName().startsWith(AI_PREFIX + "list_" + ts)); + } + } + + @Test + public void testGetAiModelById_notFound() { + UUID nonExistentId = UUID.randomUUID(); + assertReturns404(() -> client.getAiModelById(nonExistentId)); + } + + @Test + public void testGetAiModelsPagination() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 5; i++) { + createAiModel("paged_" + ts + "_" + i); + } + + PageDataAiModel page1 = client.getAiModels(2, 0, AI_PREFIX + "paged_" + ts, null, null); + assertNotNull(page1); + assertEquals(5, page1.getTotalElements().intValue()); + assertEquals(3, page1.getTotalPages().intValue()); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + PageDataAiModel lastPage = client.getAiModels(2, 2, AI_PREFIX + "paged_" + ts, null, null); + assertEquals(1, lastPage.getData().size()); + assertFalse(lastPage.getHasNext()); + } + + private AiModel buildAiModel(String name, String modelId, double temperature) { + OpenAiChatModelConfig config = new OpenAiChatModelConfig(); + config.setModelId(modelId); + config.setTemperature(temperature); + config.setMaxRetries(50); + OpenAiProviderConfig openAiProviderConfig = new OpenAiProviderConfig(); + openAiProviderConfig.setApiKey("test-api-key"); + openAiProviderConfig.setBaseUrl("https://api.openai.com/v1"); + config.setProviderConfig(openAiProviderConfig); + config.setProvider("OPENAI"); + + AiModel model = new AiModel(); + model.setName(name); + model.setConfiguration(config); + return model; + } + + private AiModel createAiModel(String suffix) throws Exception { + return client.saveAiModel(buildAiModel(AI_PREFIX + suffix, "gpt-4o", 0.7)); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AbstractApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AbstractApiClientTest.java new file mode 100644 index 0000000000..6bbda65a59 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AbstractApiClientTest.java @@ -0,0 +1,127 @@ +/** + * 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.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.junit.After; +import org.junit.Before; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.ThingsboardClient; +import org.thingsboard.client.model.ActivateUserRequest; +import org.thingsboard.client.model.Authority; +import org.thingsboard.client.model.JwtPair; +import org.thingsboard.client.model.User; +import org.thingsboard.client.model.UserId; +import org.thingsboard.server.common.data.util.ThrowingRunnable; +import org.thingsboard.server.controller.AbstractControllerTest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +@Slf4j +public abstract class AbstractApiClientTest extends AbstractControllerTest { + + protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + protected static final ObjectMapper MAPPER = new ObjectMapper(); + + protected static final String TEST_PREFIX = "ApiClientTestDevice_"; + protected static final String TEST_PREFIX_2 = "ApiClientTestDevice2_"; + protected static final String CUSTOMER_USERNAME = "javaClientCustomer@thingsboard.org"; + protected static final String TENANT_ADMIN_USERNAME = "javaClientTenant@thingsboard.org"; + protected static final String TEST_PASSWORD = "password123"; + + protected ThingsboardClient client; + + // FQN for Tenant/Customer to avoid collision with AbstractWebTest fields + protected org.thingsboard.client.model.Tenant savedClientTenant; + protected User clientTenantAdmin; + protected org.thingsboard.client.model.Customer savedClientCustomer; + protected User savedClientCustomerUser; + + @Before + public void setUpJavaClient() throws Exception { + client = ThingsboardClient.builder() + .url("http://localhost:" + wsPort) + .build(); + client.login("sysadmin@thingsboard.org", "sysadmin"); + + org.thingsboard.client.model.Tenant tenant = new org.thingsboard.client.model.Tenant(); + tenant.setTitle("Java client test tenant"); + savedClientTenant = client.saveTenant(tenant); + + clientTenantAdmin = new User(); + clientTenantAdmin.setAuthority(Authority.TENANT_ADMIN); + clientTenantAdmin.setTenantId(savedClientTenant.getId()); + clientTenantAdmin.setEmail(TENANT_ADMIN_USERNAME); + clientTenantAdmin = client.saveUser(clientTenantAdmin, "false"); + activateUserAndAuthorize(clientTenantAdmin); + + org.thingsboard.client.model.Customer customer = new org.thingsboard.client.model.Customer(); + customer.setTitle("Java client test customer"); + customer.setTenantId(savedClientTenant.getId()); + savedClientCustomer = client.saveCustomer(customer, null, null, null); + + User customerUser = new User(); + customerUser.setAuthority(Authority.CUSTOMER_USER); + customerUser.setTenantId(savedClientTenant.getId()); + customerUser.setCustomerId(savedClientCustomer.getId()); + customerUser.setEmail(CUSTOMER_USERNAME); + savedClientCustomerUser = client.saveUser(customerUser, "false"); + activateUser(savedClientCustomerUser.getId(), "password123", false); + } + + @After + public void tearDownJavaClient() { + client.login("sysadmin@thingsboard.org", "sysadmin"); + client.deleteTenant(savedClientTenant.getId().getId().toString()); + } + + protected String getBaseUrl() { + return "http://localhost:" + wsPort; + } + + protected void activateUserAndAuthorize(User user) throws ApiException { + JwtPair jwtPair = activateUser(user.getId(), TEST_PASSWORD, false); + client.setToken(jwtPair.getToken()); + } + + protected JwtPair activateUser(UserId userId, String password, boolean sendActivationMail) throws ApiException { + ActivateUserRequest activateRequest = new ActivateUserRequest(); + activateRequest.setActivateToken(getActivateToken(userId)); + activateRequest.setPassword(password); + return client.activateUser(activateRequest, sendActivationMail); + } + + protected String getActivateToken(UserId userId) throws ApiException { + String activateTokenRegex = "/api/noauth/activate?activateToken="; + String activationLink = client.getActivationLink(userId.getId().toString()); + return activationLink.substring(activationLink.lastIndexOf(activateTokenRegex) + activateTokenRegex.length()); + } + + protected void assertReturns404(ThrowingRunnable operation) { + try { + operation.run(); + fail("Expected ApiException with 404 status code"); + } catch (ApiException exception) { + assertEquals("Expected 404 status code but got " + exception.getCode(), + 404, exception.getCode()); + } catch (Exception e) { + fail("Expected ApiException but got " + e.getClass().getName() + ": " + e.getMessage()); + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AdminApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AdminApiClientTest.java new file mode 100644 index 0000000000..4d11bf3fbe --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AdminApiClientTest.java @@ -0,0 +1,99 @@ +/** + * 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.client; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; +import org.thingsboard.client.model.AdminSettings; +import org.thingsboard.client.model.FeaturesInfo; +import org.thingsboard.client.model.JwtSettings; +import org.thingsboard.client.model.SecuritySettings; +import org.thingsboard.client.model.SystemInfo; +import org.thingsboard.client.model.UpdateMessage; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class AdminApiClientTest extends AbstractApiClientTest { + + @Test + public void testAdminSettingsLifecycle() throws Exception { + // authenticate as sysadmin for admin settings management + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // get mail settings + AdminSettings mailSettings = client.getAdminSettings("mail"); + assertNotNull(mailSettings); + assertNotNull(mailSettings.getKey()); + assertEquals("mail", mailSettings.getKey()); + assertNotNull(mailSettings.getJsonValue()); + + // get general settings + AdminSettings generalSettings = client.getAdminSettings("general"); + assertNotNull(generalSettings); + assertEquals("general", generalSettings.getKey()); + assertNotNull(generalSettings.getJsonValue()); + assertNotNull(generalSettings.getJsonValue().get("baseUrl").asText()); + + // update general settings and restore + ((ObjectNode) generalSettings.getJsonValue()).put("prohibitDifferentUrl", true); + AdminSettings updatedGeneralSettings = client.saveAdminSettings(generalSettings); + assertTrue(updatedGeneralSettings.getJsonValue().get("prohibitDifferentUrl").asBoolean()); + + // get security settings + SecuritySettings securitySettings = client.getSecuritySettings(); + assertNotNull(securitySettings); + assertNotNull(securitySettings.getPasswordPolicy()); + Integer originalMaxAttempts = securitySettings.getMaxFailedLoginAttempts(); + + // update security settings + securitySettings.setMaxFailedLoginAttempts(10); + SecuritySettings updatedSecurity = client.saveSecuritySettings(securitySettings); + assertNotNull(updatedSecurity); + assertEquals(10, updatedSecurity.getMaxFailedLoginAttempts().intValue()); + + // restore original security settings + updatedSecurity.setMaxFailedLoginAttempts(originalMaxAttempts); + client.saveSecuritySettings(updatedSecurity); + + // get JWT settings + JwtSettings jwtSettings = client.getJwtSettings(); + assertNotNull(jwtSettings); + assertNotNull(jwtSettings.getTokenExpirationTime()); + assertNotNull(jwtSettings.getRefreshTokenExpTime()); + assertEquals("thingsboard.io", jwtSettings.getTokenIssuer()); + assertNotNull(jwtSettings.getTokenSigningKey()); + + // get system info + SystemInfo systemInfo = client.getSystemInfo(); + assertNotNull(systemInfo); + + // get features info + FeaturesInfo featuresInfo = client.getFeaturesInfo(); + assertNotNull(featuresInfo); + assertFalse(featuresInfo.getSmsEnabled()); + assertFalse(featuresInfo.getOauthEnabled()); + + // check updates + UpdateMessage updateMessage = client.checkUpdates(); + assertNotNull(updateMessage); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AlarmApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AlarmApiClientTest.java new file mode 100644 index 0000000000..0ebc247018 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AlarmApiClientTest.java @@ -0,0 +1,163 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.Alarm; +import org.thingsboard.client.model.AlarmInfo; +import org.thingsboard.client.model.AlarmSeverity; +import org.thingsboard.client.model.AlarmStatus; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.EntitySubtype; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.PageDataAlarmInfo; +import org.thingsboard.client.model.PageDataEntitySubtype; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class AlarmApiClientTest extends AbstractApiClientTest { + + @Test + public void testAlarmLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdAlarms = new ArrayList<>(); + + // First, create devices to attach alarms to + Device device1 = new Device(); + device1.setName("Device_For_Alarm_" + timestamp + "_1"); + device1.setType("default"); + Device createdDevice1 = client.saveDevice(device1, null, null, null, null); + + Device device2 = new Device(); + device2.setName("Device_For_Alarm_" + timestamp + "_2"); + device2.setType("thermostat"); + Device createdDevice2 = client.saveDevice(device2, null, null, null, null); + + // Create 2 alarms (1 for each device) + for (int i = 0; i < 2; i++) { + Alarm alarm = new Alarm(); + alarm.setType(((i % 2 == 0) ? "Temperature Alarm" : "Connection Alarm")); + alarm.setSeverity(((i % 2 == 0) ? AlarmSeverity.CRITICAL : AlarmSeverity.WARNING)); + alarm.setOriginator((i % 2 == 0) ? createdDevice1.getId() : createdDevice2.getId()); + + Alarm createdAlarm = client.saveAlarm(alarm); + assertNotNull(createdAlarm); + assertNotNull(createdAlarm.getId()); + assertEquals(alarm.getType(), createdAlarm.getType()); + assertEquals(alarm.getSeverity(), createdAlarm.getSeverity()); + + createdAlarms.add(createdAlarm); + } + + // Get all alarms + PageDataAlarmInfo allAlarms = client.getAllAlarms(100, 0, null, null, null, null, null, null, null, null, null); + + assertNotNull(allAlarms); + assertNotNull(allAlarms.getData()); + int initialSize = allAlarms.getData().size(); + assertEquals("Expected at least 2 alarms, but got " + initialSize, 2, initialSize); + + // Get alarms by entity (device1) + PageDataAlarmInfo device1Alarms = client.getAlarmsV2(EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), 100, 0, null, null, null, null, null, null, null, null, null); + assertNotNull(device1Alarms); + assertEquals("Expected 1 alarms for device1", 1, device1Alarms.getData().size()); + + // Get alarm by id + Alarm searchAlarm = createdAlarms.get(0); + Alarm fetchedAlarm = client.getAlarmById(searchAlarm.getId().getId().toString()); + assertEquals(searchAlarm.getType(), fetchedAlarm.getType()); + assertEquals(searchAlarm.getSeverity(), fetchedAlarm.getSeverity()); + + // Get alarm info + AlarmInfo alarmInfo = client.getAlarmInfoById(searchAlarm.getId().getId().toString()); + assertNotNull(alarmInfo); + assertEquals(searchAlarm.getId().getId(), alarmInfo.getId().getId()); + + // Acknowledge alarm + client.ackAlarm(searchAlarm.getId().getId().toString()); + + // Verify alarm is acknowledged + Alarm ackedAlarm = client.getAlarmById(searchAlarm.getId().getId().toString()); + assertEquals(AlarmStatus.ACTIVE_ACK, ackedAlarm.getStatus()); + + // Clear alarm + client.clearAlarm(searchAlarm.getId().getId().toString()); + + // Verify alarm is cleared + Alarm clearedAlarm = client.getAlarmById(searchAlarm.getId().getId().toString()); + assertEquals(AlarmStatus.CLEARED_ACK, clearedAlarm.getStatus()); + + // Get highest severity alarm for device + AlarmSeverity highestSeverity = client.getHighestAlarmSeverity(EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), null, null, null); + assertNotNull(highestSeverity); + assertEquals(AlarmSeverity.CRITICAL, highestSeverity); + + // Assign alarm to customer + client.assignAlarm(createdAlarms.get(0).getId().getId().toString(), clientTenantAdmin.getId().getId().toString()); + + // Verify assignment + Alarm assignedAlarm = client.getAlarmById(createdAlarms.get(0).getId().getId().toString()); + assertEquals(clientTenantAdmin.getId().getId(), assignedAlarm.getAssigneeId().getId()); + + // Unassign alarm + client.unassignAlarm(createdAlarms.get(0).getId().getId().toString()); + + // Verify unassignment + Alarm unassignedAlarm = client.getAlarmById(createdAlarms.get(0).getId().getId().toString()); + assertNull(unassignedAlarm.getAssigneeId()); + + // Get alarm types + PageDataEntitySubtype pageDataEntitySubtype = client.getAlarmTypes(100, 0, null, null); + assertEquals(2, pageDataEntitySubtype.getData().size()); + List alarmTypes = pageDataEntitySubtype.getData().stream() + .map(EntitySubtype::getType) + .collect(Collectors.toList()); + assertTrue(alarmTypes.containsAll(List.of("Temperature Alarm", "Connection Alarm"))); + + // Get alarms V2 (alternative endpoint) + PageDataAlarmInfo alarmsV2 = client.getAlarmsV2(EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), 100, 0, null, null, null, null, null, null, null, null, null); + assertNotNull(alarmsV2); + assertEquals(1, alarmsV2.getData().size()); + + // Get all alarms V2 + PageDataAlarmInfo allAlarmsV2 = client.getAllAlarmsV2(100, 0, null, null, null, null, null, null, null, null, null); + assertEquals(2, allAlarmsV2.getData().size()); + + // Delete alarm + UUID alarmToDeleteId = createdAlarms.get(0).getId().getId(); + client.deleteAlarm(alarmToDeleteId.toString()); + + // Verify the alarm is deleted (should return 404) + assertReturns404(() -> + client.getAlarmById(alarmToDeleteId.toString()) + ); + + // Verify count after deletion + PageDataAlarmInfo alarmsAfterDelete = client.getAllAlarms(100, 0, null, null, null, null, null, null, null, null, null); + assertEquals(initialSize - 1, alarmsAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java new file mode 100644 index 0000000000..31da96b7b4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.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.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; +import org.thingsboard.client.model.Alarm; +import org.thingsboard.client.model.AlarmComment; +import org.thingsboard.client.model.AlarmCommentInfo; +import org.thingsboard.client.model.AlarmSeverity; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.PageDataAlarmCommentInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class AlarmCommentApiClientTest extends AbstractApiClientTest { + + @Test + public void testAlarmComments() throws Exception { + long timestamp = System.currentTimeMillis(); + + // Create device for alarm + Device device = new Device(); + device.setName("Device_For_Comments_" + timestamp); + device.setType("default"); + Device createdDevice = client.saveDevice(device, null, null, null, null); + + // Create alarm + Alarm alarm = new Alarm(); + alarm.setType("Temperature Alarm"); + alarm.setSeverity(AlarmSeverity.CRITICAL); + alarm.setOriginator(createdDevice.getId()); + + Alarm createdAlarm = client.saveAlarm(alarm); + String alarmId = createdAlarm.getId().getId().toString(); + + List createdComments = new ArrayList<>(); + + // Create multiple comments + for (int i = 0; i < 5; i++) { + AlarmComment alarmComment = new AlarmComment(); + String message = "Test comment #" + i + " at " + timestamp; + ObjectNode comment = OBJECT_MAPPER.createObjectNode().put("message", message); + alarmComment.setComment(comment); + + AlarmComment commentInfo = client.saveAlarmComment(alarmId, alarmComment); + + assertNotNull(commentInfo); + assertNotNull(commentInfo.getId()); + JsonNode commentValue = commentInfo.getComment(); + assertEquals(message, commentValue.get("message").asText()); + assertNotNull(commentInfo.getCreatedTime()); + + createdComments.add(commentInfo); + } + + // Get all comments for the alarm + PageDataAlarmCommentInfo allComments = client.getAlarmComments(alarmId, 100, 0, null, null); + assertEquals("Expected 5 comments", 5, allComments.getData().size()); + + // Update a comment + AlarmComment commentToUpdate = createdComments.get(2); + JsonNode comment = commentToUpdate.getComment(); + ((ObjectNode) comment).put("message", "New comment"); + commentToUpdate.setComment(comment); + + AlarmComment updatedComment = client.saveAlarmComment(alarmId, commentToUpdate); + assertEquals("New comment", updatedComment.getComment().get("message").asText()); + + // Delete a comment + UUID commentToDeleteId = createdComments.get(0).getId().getId(); + + client.deleteAlarmComment(alarmId, commentToDeleteId.toString()); + + // Verify comment was updated to "deleted" + PageDataAlarmCommentInfo commentsAfterDelete = client.getAlarmComments(alarmId, 100, 0, null, null); + List data = commentsAfterDelete.getData(); + AlarmCommentInfo deletedComment = data.stream() + .filter(alarmCommentInfo -> alarmCommentInfo.getId().getId().equals(commentToDeleteId)) + .findFirst() + .get(); + assertEquals("User " + clientTenantAdmin.getEmail() + " deleted his comment", deletedComment.getComment().get("text").asText()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/ApiKeyApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/ApiKeyApiClientTest.java new file mode 100644 index 0000000000..4b774a442a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/ApiKeyApiClientTest.java @@ -0,0 +1,117 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.ApiKey; +import org.thingsboard.client.model.ApiKeyInfo; +import org.thingsboard.client.model.PageDataApiKeyInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class ApiKeyApiClientTest extends AbstractApiClientTest { + + @Test + public void testApiKeyLifecycle() throws Exception { + String userId = clientTenantAdmin.getId().getId().toString(); + + ApiKeyInfo request = new ApiKeyInfo(); + request.setDescription("Test API key"); + request.setUserId(clientTenantAdmin.getId()); + request.setEnabled(true); + ApiKey created = client.saveApiKey(request); + + assertNotNull(created); + assertNotNull(created.getId()); + assertNotNull(created.getValue()); + assertFalse(created.getValue().isBlank()); + assertEquals("Test API key", created.getDescription()); + + UUID keyId = created.getId().getId(); + + PageDataApiKeyInfo keysPage = client.getUserApiKeys(userId, 100, 0, null, null, null); + assertNotNull(keysPage); + assertNotNull(keysPage.getData()); + assertTrue("Newly created API key should appear in user's key list", + keysPage.getData().stream() + .anyMatch(k -> k.getId().getId().equals(keyId))); + + client.deleteApiKey(keyId); + + PageDataApiKeyInfo keysAfterDelete = client.getUserApiKeys(userId, 100, 0, null, null, null); + assertTrue("Deleted API key should not appear in user's key list", + keysAfterDelete.getData().stream() + .noneMatch(k -> k.getId().getId().equals(keyId))); + } + + @Test + public void testEnableDisableApiKey() throws Exception { + ApiKeyInfo request = new ApiKeyInfo(); + request.setDescription("Enable/disable test key"); + request.setUserId(clientTenantAdmin.getId()); + request.setEnabled(true); + ApiKey created = client.saveApiKey(request); + assertNotNull(created); + + UUID keyId = created.getId().getId(); + + ApiKeyInfo disabled = client.enableApiKey(keyId, false); + assertNotNull(disabled); + assertEquals(Boolean.FALSE, disabled.getEnabled()); + + ApiKeyInfo enabled = client.enableApiKey(keyId, true); + assertNotNull(enabled); + assertEquals(Boolean.TRUE, enabled.getEnabled()); + + client.deleteApiKey(keyId); + } + + @Test + public void testGetUserApiKeys() throws Exception { + String userId = clientTenantAdmin.getId().getId().toString(); + + int initialCount = client.getUserApiKeys(userId, 100, 0, null, null, null) + .getData().size(); + + UUID[] createdIds = new UUID[3]; + for (int i = 0; i < 3; i++) { + ApiKeyInfo request = new ApiKeyInfo(); + request.setDescription("Paging test key " + i); + request.setUserId(clientTenantAdmin.getId()); + createdIds[i] = client.saveApiKey(request).getId().getId(); + } + + PageDataApiKeyInfo afterCreate = client.getUserApiKeys(userId, 100, 0, null, null, null); + assertEquals(initialCount + 3, afterCreate.getData().size()); + assertEquals(Long.valueOf(initialCount + 3), afterCreate.getTotalElements()); + + PageDataApiKeyInfo page1 = client.getUserApiKeys(userId, 2, 0, null, null, null); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + for (UUID id : createdIds) { + client.deleteApiKey(id); + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AssetApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AssetApiClientTest.java new file mode 100644 index 0000000000..070470ac2a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AssetApiClientTest.java @@ -0,0 +1,84 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.Asset; +import org.thingsboard.client.model.PageDataAsset; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class AssetApiClientTest extends AbstractApiClientTest { + + @Test + public void testAssetLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdAssets = new ArrayList<>(); + + // create 20 assets + for (int i = 0; i < 20; i++) { + Asset asset = new Asset(); + String assetName = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + asset.setName(assetName); + asset.setLabel("Test Asset " + i); + asset.setType(((i % 2 == 0) ? "default" : "building")); + + Asset createdAsset = client.saveAsset(asset, null, null, null); + assertNotNull(createdAsset); + assertNotNull(createdAsset.getId()); + assertEquals(assetName, createdAsset.getName()); + + createdAssets.add(createdAsset); + } + + // find all, check count + PageDataAsset allAssets = client.getTenantAssets(100, 0, null, null, null, null); + + assertNotNull(allAssets); + assertNotNull(allAssets.getData()); + int initialSize = allAssets.getData().size(); + assertEquals("Expected at least 20 assets, but got " + allAssets.getData().size(), 20, initialSize); + + //find all with search text, check count + PageDataAsset allAssetsBySearchText = client.getTenantAssets(100, 0, null, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 test assets", 10, allAssetsBySearchText.getData().size()); + + // find by id + Asset searchAsset = createdAssets.get(10); + Asset asset = client.getAssetById(searchAsset.getId().getId().toString()); + assertEquals(searchAsset.getName(), asset.getName()); + + // delete asset + UUID assetToDeleteId = createdAssets.get(0).getId().getId(); + client.deleteAsset(assetToDeleteId.toString()); + + // Verify the asset is deleted + PageDataAsset assetsAfterDelete = client.getTenantAssets(100, 0, null, null, null, null); + assertEquals(initialSize - 1, assetsAfterDelete.getData().size()); + + assertReturns404(() -> + client.getAssetById(assetToDeleteId.toString()) + ); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AssetProfileApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AssetProfileApiClientTest.java new file mode 100644 index 0000000000..7f6a2f667b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AssetProfileApiClientTest.java @@ -0,0 +1,135 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.AssetProfile; +import org.thingsboard.client.model.AssetProfileInfo; +import org.thingsboard.client.model.EntityInfo; +import org.thingsboard.client.model.PageDataAssetProfile; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class AssetProfileApiClientTest extends AbstractApiClientTest { + + @Test + public void testAssetProfileLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdProfiles = new ArrayList<>(); + + // Get initial count (there should be a default profile) + PageDataAssetProfile initialProfiles = client.getAssetProfiles(100, 0, null, null, null); + assertNotNull(initialProfiles); + int initialSize = initialProfiles.getData().size(); + assertTrue("Expected at least 1 default asset profile", initialSize == 1); + + // Get default asset profile info + AssetProfileInfo defaultProfileInfo = client.getDefaultAssetProfileInfo(); + assertNotNull(defaultProfileInfo); + assertEquals(defaultProfileInfo.getName(), "default"); + + // Create multiple asset profiles + for (int i = 0; i < 5; i++) { + AssetProfile profile = new AssetProfile(); + profile.setName("Test Asset Profile " + timestamp + "_" + i); + profile.setDescription("Test description " + i); + + AssetProfile created = client.saveAssetProfile(profile); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(profile.getName(), created.getName()); + assertEquals(profile.getDescription(), created.getDescription()); + assertFalse(created.getDefault()); + + createdProfiles.add(created); + } + + // Find all, check count + PageDataAssetProfile allProfiles = client.getAssetProfiles(100, 0, null, null, null); + assertNotNull(allProfiles); + assertEquals(initialSize + 5, allProfiles.getData().size()); + + // Find all with text search + PageDataAssetProfile filteredProfiles = client.getAssetProfiles(100, 0, "Test Asset Profile " + timestamp, null, null); + assertEquals(5, filteredProfiles.getData().size()); + + // Get by id + AssetProfile searchProfile = createdProfiles.get(2); + AssetProfile fetchedProfile = client.getAssetProfileById(searchProfile.getId().getId().toString(), false); + assertEquals(searchProfile.getName(), fetchedProfile.getName()); + assertEquals(searchProfile.getDescription(), fetchedProfile.getDescription()); + + // Update asset profile + fetchedProfile.setDescription("Updated description"); + AssetProfile updatedProfile = client.saveAssetProfile(fetchedProfile); + assertEquals("Updated description", updatedProfile.getDescription()); + assertEquals(fetchedProfile.getName(), updatedProfile.getName()); + + // Get asset profile info by id + AssetProfileInfo profileInfo = client.getAssetProfileInfoById(searchProfile.getId().getId().toString()); + assertNotNull(profileInfo); + assertEquals(searchProfile.getName(), profileInfo.getName()); + + // Get asset profile infos (paginated) + PageDataAssetProfile profileInfos = client.getAssetProfiles(100, 0, null, null, null); + assertNotNull(profileInfos); + assertEquals(initialSize + 5, profileInfos.getData().size()); + + // Set a profile as default + AssetProfile profileToSetDefault = createdProfiles.get(1); + AssetProfile newDefault = client.setDefaultAssetProfile(profileToSetDefault.getId().getId().toString()); + assertNotNull(newDefault); + assertTrue(newDefault.getDefault()); + + // Verify default profile info now points to the new default + AssetProfileInfo newDefaultInfo = client.getDefaultAssetProfileInfo(); + assertEquals(profileToSetDefault.getName(), newDefaultInfo.getName()); + + // Get asset profile names + List profileNames = client.getAssetProfileNames(false); + assertNotNull(profileNames); + assertEquals(createdProfiles.size() + 1, profileNames.size()); + + // Delete asset profile (cannot delete the default one, so delete a non-default one) + UUID profileToDeleteId = createdProfiles.get(0).getId().getId(); + client.deleteAssetProfile(profileToDeleteId.toString()); + + // Verify the profile is deleted + assertReturns404(() -> + client.getAssetProfileById(profileToDeleteId.toString(), false)); + + // Verify count after deletion + PageDataAssetProfile profilesAfterDelete = client.getAssetProfiles(100, 0, null, null, null); + assertEquals(initialSize + 4, profilesAfterDelete.getData().size()); + + // Restore original default profile + AssetProfile originalDefault = initialProfiles.getData().stream() + .filter(AssetProfile::getDefault) + .findFirst() + .orElseThrow(); + client.setDefaultAssetProfile(originalDefault.getId().getId().toString()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java new file mode 100644 index 0000000000..7594485c14 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java @@ -0,0 +1,286 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.AlarmCalculatedFieldConfiguration; +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; +import org.thingsboard.client.model.CalculatedField; +import org.thingsboard.client.model.CalculatedFieldType; +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; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class CalculatedFieldApiClientTest extends AbstractApiClientTest { + + @Test + public void testCalculatedFieldLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdFields = new ArrayList<>(); + + // create devices to attach calculated fields to + Device device1 = new Device(); + device1.setName("CalcFieldDevice1_" + timestamp); + device1.setType("default"); + Device createdDevice1 = client.saveDevice(device1, null, null, null, null); + + Device device2 = new Device(); + device2.setName("CalcFieldDevice2_" + timestamp); + device2.setType("default"); + Device createdDevice2 = client.saveDevice(device2, null, null, null, null); + + // create calculated fields on device1 + for (int i = 0; i < 5; i++) { + CalculatedField cf = new CalculatedField(); + cf.setName(TEST_PREFIX + "CalcField_" + timestamp + "_" + i); + cf.setType(CalculatedFieldType.SIMPLE); + + cf.setEntityId(createdDevice1.getId()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument arg = new Argument(); + ReferencedEntityKey refKey = new ReferencedEntityKey(); + refKey.setKey("temperature"); + refKey.setType(ArgumentType.TS_LATEST); + arg.setRefEntityKey(refKey); + config.putArgumentsItem("temp", arg); + + config.setExpression("temp * " + (i + 1)); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("scaledTemp_" + i); + config.setOutput(output); + + cf.setConfiguration(config); + + CalculatedField created = client.saveCalculatedField(cf); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(cf.getName(), created.getName()); + assertEquals(CalculatedFieldType.SIMPLE, created.getType()); + + createdFields.add(created); + } + + // create calculated fields on device2 + for (int i = 0; i < 3; i++) { + CalculatedField cf = new CalculatedField(); + cf.setName(TEST_PREFIX + "CalcField2_" + timestamp + "_" + i); + cf.setType(CalculatedFieldType.SIMPLE); + cf.setEntityId(createdDevice2.getId()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument arg = new Argument(); + ReferencedEntityKey refKey = new ReferencedEntityKey(); + refKey.setKey("humidity"); + refKey.setType(ArgumentType.TS_LATEST); + arg.setRefEntityKey(refKey); + config.putArgumentsItem("hum", arg); + + config.setExpression("hum + " + i); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("adjustedHumidity_" + i); + config.setOutput(output); + + cf.setConfiguration(config); + + CalculatedField created = client.saveCalculatedField(cf); + assertNotNull(created); + createdFields.add(created); + } + + // get calculated fields by entity id for device1 + PageDataCalculatedField device1Fields = client.getCalculatedFieldsByEntityId( + EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), + 100, 0, CalculatedFieldType.SIMPLE, null, null, null); + assertNotNull(device1Fields); + assertEquals(5, device1Fields.getData().size()); + + // get calculated fields by entity id for device2 + PageDataCalculatedField device2Fields = client.getCalculatedFieldsByEntityId( + EntityType.DEVICE.toString(), createdDevice2.getId().getId().toString(), + 100, 0, CalculatedFieldType.SIMPLE, null, null, null); + assertEquals(3, device2Fields.getData().size()); + + // get by id + CalculatedField searchField = createdFields.get(2); + CalculatedField fetchedField = client.getCalculatedFieldById(searchField.getId().getId().toString()); + assertEquals(searchField.getName(), fetchedField.getName()); + assertEquals(searchField.getType(), fetchedField.getType()); + assertNotNull(fetchedField.getConfiguration()); + SimpleCalculatedFieldConfiguration fetchedConfig = + (SimpleCalculatedFieldConfiguration) fetchedField.getConfiguration(); + assertEquals("temp * 3", fetchedConfig.getExpression()); + + // update calculated field + fetchedField.setName(fetchedField.getName() + "_updated"); + fetchedConfig.setExpression("temp * 100"); + CalculatedField updatedField = client.saveCalculatedField(fetchedField); + assertEquals(fetchedField.getName(), updatedField.getName()); + SimpleCalculatedFieldConfiguration updatedConfig = + (SimpleCalculatedFieldConfiguration) updatedField.getConfiguration(); + assertEquals("temp * 100", updatedConfig.getExpression()); + + // delete calculated field + UUID fieldToDeleteId = createdFields.get(0).getId().getId(); + client.deleteCalculatedField(fieldToDeleteId.toString()); + + // verify deletion + assertReturns404(() -> + client.getCalculatedFieldById(fieldToDeleteId.toString()) + ); + + PageDataCalculatedField device1FieldsAfterDelete = client.getCalculatedFieldsByEntityId( + EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), + 100, 0, null, null, null, null); + assertEquals(4, device1FieldsAfterDelete.getData().size()); + } + + @Test + public void testAlarmCalculatedFieldLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // create a device to attach the alarm calculated field to + Device device = new Device(); + device.setName("AlarmCalcFieldDevice_" + timestamp); + device.setType("default"); + Device createdDevice = client.saveDevice(device, null, null, null, null); + + // build the alarm calculated field configuration + AlarmCalculatedFieldConfiguration config = new AlarmCalculatedFieldConfiguration(); + + // argument: temperature time-series + Argument tempArg = new Argument(); + ReferencedEntityKey refKey = new ReferencedEntityKey(); + refKey.setKey("temperature"); + refKey.setType(ArgumentType.TS_LATEST); + tempArg.setRefEntityKey(refKey); + config.putArgumentsItem("temp", tempArg); + + // create rule: HIGH_TEMPERATURE when temp > 50 (TBEL expression) + TbelAlarmConditionExpression createExpression = new TbelAlarmConditionExpression(); + createExpression.setExpression("return temp > 50;"); + SimpleAlarmCondition createCondition = new SimpleAlarmCondition(); + createCondition.setExpression(createExpression); + SpecificTimeSchedule specificTimeSchedule = new SpecificTimeSchedule().addDaysOfWeekItem(3); + AlarmConditionValueAlarmSchedule schedule = new AlarmConditionValueAlarmSchedule().staticValue(specificTimeSchedule); + createCondition.setSchedule(schedule); + AlarmRule createRule = new AlarmRule(); + createRule.setCondition(createCondition); + createRule.setAlarmDetails("Temperature is too high: ${temp}"); + config.setCreateRules(Map.of( + AlarmSeverity.CRITICAL.name(), createRule + )); + + // clear rule: when temp drops below 30 + TbelAlarmConditionExpression clearExpression = new TbelAlarmConditionExpression(); + clearExpression.setExpression("return temp < 30;"); + SimpleAlarmCondition clearCondition = new SimpleAlarmCondition(); + clearCondition.setExpression(clearExpression); + AlarmRule clearRule = new AlarmRule(); + clearRule.setCondition(clearCondition); + config.setClearRule(clearRule); + + config.setPropagate(true); + config.setPropagateToOwner(false); + + // create calculated field + CalculatedField cf = new CalculatedField(); + cf.setName(TEST_PREFIX + "AlarmCalcField_" + timestamp); + cf.setType(CalculatedFieldType.ALARM); + + cf.setEntityId(createdDevice.getId()); + cf.setConfiguration(config); + + CalculatedField created = client.saveCalculatedField(cf); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(cf.getName(), created.getName()); + assertEquals(CalculatedFieldType.ALARM, created.getType()); + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) created.getConfiguration(); + 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 + CalculatedField fetched = client.getCalculatedFieldById(created.getId().getId().toString()); + assertNotNull(fetched); + assertEquals(created.getName(), fetched.getName()); + assertEquals(CalculatedFieldType.ALARM, fetched.getType()); + assertNotNull(fetched.getConfiguration()); + AlarmCalculatedFieldConfiguration fetchedConfig = + (AlarmCalculatedFieldConfiguration) fetched.getConfiguration(); + assertNotNull(fetchedConfig.getCreateRules()); + assertEquals(1, fetchedConfig.getCreateRules().size()); + assertTrue(fetchedConfig.getCreateRules().containsKey("CRITICAL")); + assertNotNull(fetchedConfig.getClearRule()); + assertEquals(Boolean.TRUE, fetchedConfig.getPropagate()); + + // update: add a second create rule for CRITICAL_TEMPERATURE + TbelAlarmConditionExpression criticalExpression = new TbelAlarmConditionExpression(); + criticalExpression.setExpression("return temp > 80;"); + SimpleAlarmCondition criticalCondition = new SimpleAlarmCondition(); + criticalCondition.setExpression(criticalExpression); + AlarmRule criticalRule = new AlarmRule(); + criticalRule.setCondition(criticalCondition); + fetchedConfig.putCreateRulesItem(AlarmSeverity.INDETERMINATE.name(), criticalRule); + fetched.setConfiguration(fetchedConfig); + + CalculatedField updated = client.saveCalculatedField(fetched); + AlarmCalculatedFieldConfiguration updatedConfig = + (AlarmCalculatedFieldConfiguration) updated.getConfiguration(); + assertEquals(2, updatedConfig.getCreateRules().size()); + assertTrue(updatedConfig.getCreateRules().containsKey("INDETERMINATE")); + + // filter by entity and ALARM type + PageDataCalculatedField deviceFields = client.getCalculatedFieldsByEntityId( + EntityType.DEVICE.toString(), createdDevice.getId().getId().toString(), + 100, 0, CalculatedFieldType.ALARM, null, null, null); + assertNotNull(deviceFields); + assertEquals(1, deviceFields.getData().size()); + + // delete and verify + UUID fieldId = created.getId().getId(); + client.deleteCalculatedField(fieldId.toString()); + assertReturns404(() -> client.getCalculatedFieldById(fieldId.toString())); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/CustomerApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/CustomerApiClientTest.java new file mode 100644 index 0000000000..fff3670c85 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/CustomerApiClientTest.java @@ -0,0 +1,113 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.Customer; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.PageDataCustomer; +import org.thingsboard.client.model.PageDataDevice; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class CustomerApiClientTest extends AbstractApiClientTest { + + @Test + public void testCustomerLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdCustomers = new ArrayList<>(); + + // create 20 customers + for (int i = 0; i < 20; i++) { + Customer customer = new Customer(); + String customerTitle = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + customer.setTitle(customerTitle); + customer.setEmail("customer_" + timestamp + "_" + i + "@test.com"); + + Customer createdCustomer = client.saveCustomer(customer, null, null, null); + assertNotNull(createdCustomer); + assertNotNull(createdCustomer.getId()); + assertEquals(customerTitle, createdCustomer.getTitle()); + + createdCustomers.add(createdCustomer); + } + + // find all, check count (includes savedClientCustomer from AbstractApiClientTest setup) + PageDataCustomer allCustomers = client.getCustomers(100, 0, null, null, null); + assertNotNull(allCustomers); + assertNotNull(allCustomers.getData()); + int initialSize = allCustomers.getData().size(); + assertEquals("Expected 21 customers (20 created + 1 from setup), but got " + initialSize, 21, initialSize); + + // find all with search text, check count + PageDataCustomer filteredCustomers = client.getCustomers(100, 0, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 customers matching prefix", 10, filteredCustomers.getData().size()); + + // find by id + Customer searchCustomer = createdCustomers.get(10); + Customer fetchedCustomer = client.getCustomerById(searchCustomer.getId().getId().toString()); + assertEquals(searchCustomer.getTitle(), fetchedCustomer.getTitle()); + + // find by title + Customer fetchedByTitle = client.getTenantCustomer(searchCustomer.getTitle()); + assertEquals(searchCustomer.getId().getId(), fetchedByTitle.getId().getId()); + + // update customer + fetchedCustomer.setCity("New York"); + fetchedCustomer.setCountry("US"); + Customer updatedCustomer = client.saveCustomer(fetchedCustomer, null, null, null); + assertEquals("New York", updatedCustomer.getCity()); + assertEquals("US", updatedCustomer.getCountry()); + + // assign device to customer and verify + Device device = new Device(); + device.setName("CustomerTestDevice_" + timestamp); + device.setType("default"); + Device createdDevice = client.saveDevice(device, null, null, null, null); + + String customerId = createdCustomers.get(0).getId().getId().toString(); + client.assignDeviceToCustomer(customerId, createdDevice.getId().getId().toString()); + + PageDataDevice customerDevices = client.getCustomerDevices(customerId, 100, 0, null, null, null, null); + assertEquals(1, customerDevices.getData().size()); + assertEquals(createdDevice.getName(), customerDevices.getData().get(0).getName()); + + // unassign device from customer + client.unassignDeviceFromCustomer(createdDevice.getId().getId().toString()); + PageDataDevice devicesAfterUnassign = client.getCustomerDevices(customerId, 100, 0, null, null, null, null); + assertEquals(0, devicesAfterUnassign.getData().size()); + + // delete customer + UUID customerToDeleteId = createdCustomers.get(0).getId().getId(); + client.deleteCustomer(customerToDeleteId.toString()); + + // verify deletion + PageDataCustomer customersAfterDelete = client.getCustomers(100, 0, null, null, null); + assertEquals(initialSize - 1, customersAfterDelete.getData().size()); + + assertReturns404(() -> + client.getCustomerById(customerToDeleteId.toString()) + ); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DashboardApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DashboardApiClientTest.java new file mode 100644 index 0000000000..386e2db84f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DashboardApiClientTest.java @@ -0,0 +1,123 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.Dashboard; +import org.thingsboard.client.model.DashboardInfo; +import org.thingsboard.client.model.PageDataDashboardInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class DashboardApiClientTest extends AbstractApiClientTest { + + @Test + public void testDashboardLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // create 20 dashboards + for (int i = 0; i < 20; i++) { + Dashboard dashboard = new Dashboard(); + String dashboardTitle = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + dashboard.setTitle(dashboardTitle); + + client.saveDashboard(dashboard, null); + } + + // find all, check count + PageDataDashboardInfo allDashboards = client.getTenantDashboards(100, 0, null, null, null, null); + assertNotNull(allDashboards); + assertNotNull(allDashboards.getData()); + int initialSize = allDashboards.getData().size(); + assertEquals("Expected 20 dashboards, but got " + initialSize, 20, initialSize); + + List createdDashboards = allDashboards.getData(); + + // find all with search text, check count + PageDataDashboardInfo filteredDashboards = client.getTenantDashboards(100, 0, null, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 dashboards matching prefix", 10, filteredDashboards.getData().size()); + + // find by id + DashboardInfo searchDashboard = createdDashboards.get(10); + DashboardInfo fetchedDashboard = client.getDashboardInfoById(searchDashboard.getId().getId().toString()); + assertEquals(searchDashboard.getTitle(), fetchedDashboard.getTitle()); + + // update dashboard + Dashboard dashboardToUpdate = new Dashboard(); + dashboardToUpdate.setId(fetchedDashboard.getId()); + dashboardToUpdate.setTitle(fetchedDashboard.getTitle() + "_updated"); + dashboardToUpdate.setVersion(fetchedDashboard.getVersion()); + client.saveDashboard(dashboardToUpdate, null); + + DashboardInfo updatedDashboard = client.getDashboardInfoById(fetchedDashboard.getId().getId().toString()); + assertEquals(fetchedDashboard.getTitle() + "_updated", updatedDashboard.getTitle()); + + // assign dashboard to customer and verify + String customerId = savedClientCustomer.getId().getId().toString(); + String dashboardId = createdDashboards.get(0).getId().getId().toString(); + client.assignDashboardToCustomer(customerId, dashboardId); + + PageDataDashboardInfo customerDashboards = client.getCustomerDashboards(customerId, 100, 0, null, null, null, null); + assertEquals(1, customerDashboards.getData().size()); + assertEquals(createdDashboards.get(0).getTitle(), customerDashboards.getData().get(0).getTitle()); + + // unassign dashboard from customer + client.unassignDashboardFromCustomer(customerId, dashboardId); + PageDataDashboardInfo dashboardsAfterUnassign = client.getCustomerDashboards(customerId, 100, 0, null, null, null, null); + assertEquals(0, dashboardsAfterUnassign.getData().size()); + + // make dashboard public and verify + client.assignDashboardToPublicCustomer(dashboardId); + DashboardInfo publicDashboard = client.getDashboardInfoById(dashboardId); + assertNotNull(publicDashboard.getAssignedCustomers()); + assertTrue(publicDashboard.getAssignedCustomers().size() > 0); + + // remove public access + client.unassignDashboardFromPublicCustomer(dashboardId); + + // delete dashboard + UUID dashboardToDeleteId = createdDashboards.get(0).getId().getId(); + client.deleteDashboard(dashboardToDeleteId.toString()); + + // verify deletion + PageDataDashboardInfo dashboardsAfterDelete = client.getTenantDashboards(100, 0, null, null, null, null); + assertEquals(initialSize - 1, dashboardsAfterDelete.getData().size()); + + assertReturns404(() -> + client.getDashboardInfoById(dashboardToDeleteId.toString()) + ); + } + + @Test + public void testGetServerTime() throws Exception { + Long serverTime = client.getServerTime(); + assertNotNull(serverTime); + } + + @Test + public void testGetMaxDatapointsLimit() throws Exception { + Long maxDatapointsLimit = client.getMaxDatapointsLimit(); + assertNotNull(maxDatapointsLimit); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DeviceApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DeviceApiClientTest.java new file mode 100644 index 0000000000..f3695b9fc1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DeviceApiClientTest.java @@ -0,0 +1,114 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.DeviceCredentials; +import org.thingsboard.client.model.DeviceCredentialsType; +import org.thingsboard.client.model.PageDataDevice; +import org.thingsboard.client.model.SaveDeviceWithCredentialsRequest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class DeviceApiClientTest extends AbstractApiClientTest { + + @Test + public void testDeviceLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdDevices = new ArrayList<>(); + + // create 20 devices + for (int i = 0; i < 20; i++) { + Device device = new Device(); + String deviceName = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + device.setName(deviceName); + device.setLabel("Test Device " + i); + device.setType(((i % 2 == 0) ? "default" : "thermostat")); + + Device createdDevice = client.saveDevice(device, null, null, null, null); + assertNotNull(createdDevice); + assertNotNull(createdDevice.getId()); + assertEquals(deviceName, createdDevice.getName()); + + createdDevices.add(createdDevice); + } + + // find all, check count + PageDataDevice allDevices = client.getTenantDevices(100, 0, null, null, null, null); + + assertNotNull(allDevices); + assertNotNull(allDevices.getData()); + int initialSize = allDevices.getData().size(); + assertEquals("Expected at least 20 devices, but got " + allDevices.getData().size(), 20, initialSize); + + //find all with search text, check count + PageDataDevice allDevicesBySearchText = client.getTenantDevices(10, 0, null, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 test devices", 10, allDevicesBySearchText.getData().size()); + + // find by id + Device searchDevice = createdDevices.get(10); + Device device = client.getDeviceById(searchDevice.getId().getId().toString()); + assertEquals(searchDevice.getName(), device.getName()); + + // create device with credentials + Device deviceWithCreds = new Device(); + deviceWithCreds.setName("device-with-creds"); + + DeviceCredentials creds = new DeviceCredentials(); + creds.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + creds.setCredentialsId("TEST_ACCESS_TOKEN"); + + SaveDeviceWithCredentialsRequest request = new SaveDeviceWithCredentialsRequest(); + request.setDevice(deviceWithCreds); + request.setCredentials(creds); + + Device savedDeviceWithCreds = client.saveDeviceWithCredentials(request, null, null, null); + assertEquals("device-with-creds", savedDeviceWithCreds.getName()); + + // find credentials by device id + DeviceCredentials fetchedCreds = client.getDeviceCredentialsByDeviceId(savedDeviceWithCreds.getId().getId().toString()); + assertEquals(creds.getCredentialsId(), fetchedCreds.getCredentialsId()); + + // delete device + UUID deviceToDeleteId = createdDevices.get(0).getId().getId(); + client.deleteDevice(deviceToDeleteId.toString()); + + // Verify the device is deleted + PageDataDevice devicesAfterDelete = client.getTenantDevices(100, 0, null, null, null, null); + assertEquals(initialSize, devicesAfterDelete.getData().size()); + + assertReturns404(() -> + client.getDeviceById(deviceToDeleteId.toString())); + + // assign device to customer + client.assignDeviceToCustomer(savedClientCustomer.getId().getId().toString(), savedDeviceWithCreds.getId().getId().toString()); + + // check customer devices + PageDataDevice pageDataDevice = client.getCustomerDevices(savedClientCustomer.getId().getId().toString(), 100, 0, null, null, null, null); + List data = pageDataDevice.getData(); + assertEquals(1, data.size()); + assertEquals(savedDeviceWithCreds.getName(), data.get(0).getName()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DeviceConnectivityApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DeviceConnectivityApiClientTest.java new file mode 100644 index 0000000000..d8204cab41 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DeviceConnectivityApiClientTest.java @@ -0,0 +1,53 @@ +/** + * 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.client; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.Test; +import org.thingsboard.client.model.Device; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; + +@DaoSqlTest +public class DeviceConnectivityApiClientTest extends AbstractApiClientTest { + + @Test + public void testGetDevicePublishTelemetryCommands() throws Exception { + Device device = new Device(); + device.setName(TEST_PREFIX + System.currentTimeMillis()); + device.setType("default"); + + Device savedDevice = client.saveDevice(device, null, null, null, null); + String token = client.getDeviceCredentialsByDeviceId(savedDevice.getId().getId().toString()).getCredentialsId(); + + String deviceId = savedDevice.getId().getId().toString(); + + JsonNode commands = client.getDevicePublishTelemetryCommands(deviceId); + assertEquals("curl -v -X POST http://localhost:8080/api/v1/" + token + "/telemetry --header Content-Type:application/json --data \"{temperature:25}\"", commands.get("http").get("http").asText()); + assertEquals("mosquitto_pub -d -q 1 -h localhost -p 1883 -t v1/devices/me/telemetry -u \"" + token + "\" -m \"{temperature:25}\"", commands.get("mqtt").get("mqtt").asText()); + assertEquals("coap-client -v 6 -m POST -t \"application/json\" -e \"{temperature:25}\" coap://localhost:5683/api/v1/" + token + "/telemetry", commands.get("coap").get("coap").asText()); + } + + @Test + public void testGetDevicePublishTelemetryCommands_nonExistentDevice() { + String nonExistentId = UUID.randomUUID().toString(); + assertReturns404(() -> client.getDevicePublishTelemetryCommands(nonExistentId)); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DeviceProfileApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DeviceProfileApiClientTest.java new file mode 100644 index 0000000000..e8fcb4ffd1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DeviceProfileApiClientTest.java @@ -0,0 +1,157 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.DefaultDeviceProfileConfiguration; +import org.thingsboard.client.model.DefaultDeviceProfileTransportConfiguration; +import org.thingsboard.client.model.DeviceProfile; +import org.thingsboard.client.model.DeviceProfileData; +import org.thingsboard.client.model.DeviceProfileInfo; +import org.thingsboard.client.model.DeviceProfileType; +import org.thingsboard.client.model.DeviceTransportType; +import org.thingsboard.client.model.EntityInfo; +import org.thingsboard.client.model.PageDataDeviceProfile; +import org.thingsboard.client.model.PageDataDeviceProfileInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class DeviceProfileApiClientTest extends AbstractApiClientTest { + + @Test + public void testDeviceProfileLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdProfiles = new ArrayList<>(); + + // Get initial count (there should be a default profile) + PageDataDeviceProfile initialProfiles = client.getDeviceProfiles(100, 0, null, null, null); + assertNotNull(initialProfiles); + int initialSize = initialProfiles.getData().size(); + assertTrue("Expected at least 1 default device profile", initialSize >= 1); + + // Get default device profile info + DeviceProfileInfo defaultProfileInfo = client.getDefaultDeviceProfileInfo(); + assertNotNull(defaultProfileInfo); + assertNotNull(defaultProfileInfo.getName()); + + // Create multiple device profiles + for (int i = 0; i < 5; i++) { + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setName("Test Device Profile " + timestamp + "_" + i); + deviceProfile.setDescription("Test description " + i); + deviceProfile.setType(DeviceProfileType.DEFAULT); + deviceProfile.setTransportType(DeviceTransportType.DEFAULT); + + DeviceProfileData deviceProfileData = new DeviceProfileData(); + DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration(); + configuration.setType(DeviceProfileType.DEFAULT.getValue()); + deviceProfileData.setConfiguration(configuration); + DefaultDeviceProfileTransportConfiguration transportConf = new DefaultDeviceProfileTransportConfiguration(); + transportConf.setType(DeviceTransportType.DEFAULT.getValue()); + deviceProfileData.setTransportConfiguration(transportConf); + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setDefault(false); + deviceProfile.setDefaultRuleChainId(null); + + DeviceProfile created = client.saveDeviceProfile(deviceProfile); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(deviceProfile.getName(), created.getName()); + assertEquals(deviceProfile.getDescription(), created.getDescription()); + assertEquals(DeviceProfileType.DEFAULT, created.getType()); + assertEquals(DeviceTransportType.DEFAULT, created.getTransportType()); + assertFalse(created.getDefault()); + + createdProfiles.add(created); + } + + // Find all, check count + PageDataDeviceProfile allProfiles = client.getDeviceProfiles(100, 0, null, null, null); + assertNotNull(allProfiles); + assertEquals(initialSize + 5, allProfiles.getData().size()); + + // Find all with text search + PageDataDeviceProfile filteredProfiles = client.getDeviceProfiles(100, 0, "Test Device Profile " + timestamp, null, null); + assertEquals(5, filteredProfiles.getData().size()); + + // Get by id + DeviceProfile searchProfile = createdProfiles.get(2); + DeviceProfile fetchedProfile = client.getDeviceProfileById(searchProfile.getId().getId().toString(), false); + assertEquals(searchProfile.getName(), fetchedProfile.getName()); + assertEquals(searchProfile.getDescription(), fetchedProfile.getDescription()); + + // Update device profile + fetchedProfile.setDescription("Updated description"); + DeviceProfile updatedProfile = client.saveDeviceProfile(fetchedProfile); + assertEquals("Updated description", updatedProfile.getDescription()); + assertEquals(fetchedProfile.getName(), updatedProfile.getName()); + + // Get device profile info by id + DeviceProfileInfo profileInfo = client.getDefaultDeviceProfileInfo(); + assertNotNull(profileInfo); + assertEquals(searchProfile.getType().getValue().toLowerCase(), profileInfo.getName()); + assertEquals(DeviceTransportType.DEFAULT, profileInfo.getTransportType()); + + // Get device profile infos (paginated) + PageDataDeviceProfileInfo profileInfos = client.getDeviceProfileInfos(100, 0, null, null, null, null); + assertNotNull(profileInfos); + assertEquals(initialSize + 5, profileInfos.getData().size()); + + // Set a profile as default + DeviceProfile profileToSetDefault = createdProfiles.get(1); + DeviceProfile newDefault = client.setDefaultDeviceProfile(profileToSetDefault.getId().getId().toString()); + assertNotNull(newDefault); + assertTrue(newDefault.getDefault()); + + // Verify default profile info now points to the new default + DeviceProfileInfo newDefaultInfo = client.getDefaultDeviceProfileInfo(); + assertEquals(profileToSetDefault.getName(), newDefaultInfo.getName()); + + // Get device profile names + List profileNames = client.getDeviceProfileNames(false); + assertNotNull(profileNames); + assertEquals(createdProfiles.size() + 1, profileNames.size()); + + // Delete device profile (cannot delete the default one, so delete a non-default one) + UUID profileToDeleteId = createdProfiles.get(0).getId().getId(); + client.deleteDeviceProfile(profileToDeleteId.toString()); + + // Verify the profile is deleted + assertReturns404(() -> + client.getDeviceProfileById(profileToDeleteId.toString(), false)); + + // Verify count after deletion + PageDataDeviceProfile profilesAfterDelete = client.getDeviceProfiles(100, 0, null, null, null); + assertEquals(initialSize + 4, profilesAfterDelete.getData().size()); + + // Restore original default profile + DeviceProfile originalDefault = initialProfiles.getData().stream() + .filter(DeviceProfile::getDefault) + .findFirst() + .orElseThrow(); + client.setDefaultDeviceProfile(originalDefault.getId().getId().toString()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java new file mode 100644 index 0000000000..e9498a1c50 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java @@ -0,0 +1,105 @@ +/** + * 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.client; + +import org.junit.After; +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.Domain; +import org.thingsboard.client.model.DomainInfo; +import org.thingsboard.client.model.PageDataDomainInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class DomainApiClientTest extends AbstractApiClientTest { + + List createdDomains = new ArrayList<>(); + + @After + public void afterDomainTest() { + createdDomains.forEach(domain -> { + try { + client.deleteDomain(domain.getId().getId()); + } catch (ApiException e) { + // ignore + } + }); + } + + @Test + public void testDomainLifecycle() throws Exception { + client.login("sysadmin@thingsboard.org", "sysadmin"); + + long timestamp = System.currentTimeMillis(); + + // create 5 domains + for (int i = 0; i < 5; i++) { + Domain domain = new Domain(); + domain.setName("domain." + i + ".com"); + domain.setOauth2Enabled(false); + domain.setPropagateToEdge(false); + + Domain created = client.saveDomain(domain, null); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(domain.getName(), created.getName()); + assertEquals(false, created.getOauth2Enabled()); + + createdDomains.add(created); + } + + // list tenant domains with text search + PageDataDomainInfo filteredDomains = client.getDomainInfos(100, 0, + "domain.", null, null); + assertNotNull(filteredDomains); + assertEquals(5, filteredDomains.getData().size()); + + // get domain info by id + Domain searchDomain = createdDomains.get(2); + DomainInfo fetchedInfo = client.getDomainInfoById(searchDomain.getId().getId()); + assertEquals(searchDomain.getName(), fetchedInfo.getName()); + assertEquals(searchDomain.getOauth2Enabled(), fetchedInfo.getOauth2Enabled()); + assertNotNull(fetchedInfo.getOauth2ClientInfos()); + + // update domain + Domain domainToUpdate = createdDomains.get(3); + domainToUpdate.setPropagateToEdge(true); + Domain updatedDomain = client.saveDomain(domainToUpdate, null); + assertEquals(true, updatedDomain.getPropagateToEdge()); + + // delete domain + UUID domainToDeleteId = createdDomains.get(0).getId().getId(); + createdDomains.remove(0); + client.deleteDomain(domainToDeleteId); + + // verify deletion + assertReturns404(() -> + client.getDomainInfoById(domainToDeleteId) + ); + + 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/EdgeApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/EdgeApiClientTest.java new file mode 100644 index 0000000000..6aeb7f0ef6 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/EdgeApiClientTest.java @@ -0,0 +1,141 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.Edge; +import org.thingsboard.client.model.EdgeInfo; +import org.thingsboard.client.model.PageDataEdge; +import org.thingsboard.client.model.PageDataEdgeInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class EdgeApiClientTest extends AbstractApiClientTest { + + @Test + public void testEdgeLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdEdges = new ArrayList<>(); + + // create 5 edges + for (int i = 0; i < 5; i++) { + Edge edge = new Edge(); + edge.setName(TEST_PREFIX + "Edge_" + timestamp + "_" + i); + edge.setType("gateway"); + edge.setLabel("Test Edge " + i); + edge.setRoutingKey("routing_key_" + timestamp + "_" + i); + edge.setSecret("secret_key_" + timestamp + "_" + i); + + Edge created = client.saveEdge(edge); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(edge.getName(), created.getName()); + assertEquals("gateway", created.getType()); + assertNotNull(created.getRoutingKey()); + assertNotNull(created.getSecret()); + + createdEdges.add(created); + } + + // list tenant edges with text search + PageDataEdge filteredEdges = client.getTenantEdges(100, 0, null, + TEST_PREFIX + "Edge_" + timestamp, null, null); + assertNotNull(filteredEdges); + assertEquals(5, filteredEdges.getData().size()); + + // list tenant edges with type filter + PageDataEdge typedEdges = client.getTenantEdges(100, 0, "gateway", + TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(5, typedEdges.getData().size()); + + // get tenant edge infos + PageDataEdgeInfo edgeInfos = client.getTenantEdgeInfos(100, 0, null, + TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(5, edgeInfos.getData().size()); + + // get edge by id + Edge searchEdge = createdEdges.get(2); + Edge fetchedEdge = client.getEdgeById(searchEdge.getId().getId().toString()); + assertEquals(searchEdge.getName(), fetchedEdge.getName()); + assertEquals(searchEdge.getType(), fetchedEdge.getType()); + assertEquals(searchEdge.getRoutingKey(), fetchedEdge.getRoutingKey()); + + // get edge by name + Edge fetchedByName = client.getTenantEdgeByName(searchEdge.getName()); + assertEquals(searchEdge.getId().getId(), fetchedByName.getId().getId()); + + // get edges by list of ids + List idsToFetch = List.of( + createdEdges.get(0).getId().getId().toString(), + createdEdges.get(1).getId().getId().toString() + ); + List edgeList = client.getEdgeList(idsToFetch); + assertEquals(2, edgeList.size()); + + // update edge + Edge edgeToUpdate = createdEdges.get(3); + edgeToUpdate.setLabel("Updated Label"); + Edge updatedEdge = client.saveEdge(edgeToUpdate); + assertEquals("Updated Label", updatedEdge.getLabel()); + + // assign edge to customer + String customerId = savedClientCustomer.getId().getId().toString(); + String edgeId = createdEdges.get(1).getId().getId().toString(); + Edge assignedEdge = client.assignEdgeToCustomer(customerId, edgeId); + assertNotNull(assignedEdge.getCustomerId()); + + // get customer edges + PageDataEdge customerEdges = client.getCustomerEdges(customerId, 100, 0, + null, TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(1, customerEdges.getData().size()); + + // get customer edge infos + PageDataEdgeInfo customerEdgeInfos = client.getCustomerEdgeInfos(customerId, 100, 0, + null, TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(1, customerEdgeInfos.getData().size()); + EdgeInfo edgeInfo = customerEdgeInfos.getData().get(0); + assertNotNull(edgeInfo.getCustomerTitle()); + + // unassign edge from customer + Edge unassignedEdge = client.unassignEdgeFromCustomer(edgeId); + assertNotNull(unassignedEdge); + + PageDataEdge customerEdgesAfter = client.getCustomerEdges(customerId, 100, 0, + null, TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(0, customerEdgesAfter.getData().size()); + + // delete edge + UUID edgeToDeleteId = createdEdges.get(0).getId().getId(); + client.deleteEdge(edgeToDeleteId.toString()); + + // verify deletion + assertReturns404(() -> + client.getEdgeById(edgeToDeleteId.toString()) + ); + + PageDataEdge edgesAfterDelete = client.getTenantEdges(100, 0, null, + TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(4, edgesAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/EntityQueryApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/EntityQueryApiClientTest.java new file mode 100644 index 0000000000..2571306eb6 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/EntityQueryApiClientTest.java @@ -0,0 +1,289 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.AliasEntityId; +import org.thingsboard.client.model.Asset; +import org.thingsboard.client.model.AssetTypeFilter; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.DeviceTypeFilter; +import org.thingsboard.client.model.Direction; +import org.thingsboard.client.model.EntityData; +import org.thingsboard.client.model.EntityDataPageLink; +import org.thingsboard.client.model.EntityDataQuery; +import org.thingsboard.client.model.EntityDataSortOrder; +import org.thingsboard.client.model.EntityKey; +import org.thingsboard.client.model.EntityKeyType; +import org.thingsboard.client.model.EntityKeyValueType; +import org.thingsboard.client.model.EntityListFilter; +import org.thingsboard.client.model.EntityNameFilter; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.FilterPredicateValueString; +import org.thingsboard.client.model.KeyFilter; +import org.thingsboard.client.model.PageDataEntityData; +import org.thingsboard.client.model.SingleEntityFilter; +import org.thingsboard.client.model.StringFilterPredicate; +import org.thingsboard.client.model.StringOperation; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class EntityQueryApiClientTest extends AbstractApiClientTest { + + private static final String QUERY_TEST_PREFIX = "QueryTest_"; + + private EntityDataPageLink pageLink(int pageSize) { + return new EntityDataPageLink() + .pageSize(pageSize) + .page(0) + .sortOrder(new EntityDataSortOrder() + .key(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")) + .direction(Direction.ASC)); + } + + @Test + public void testFindByDeviceTypeFilter() throws Exception { + long ts = System.currentTimeMillis(); + String type1 = "temperatureSensor"; + String type2 = "humiditySensor"; + + for (int i = 0; i < 3; i++) { + Device d = new Device(); + d.setName(QUERY_TEST_PREFIX + "temp_" + ts + "_" + i); + d.setType(type1); + client.saveDevice(d, null, null, null, null); + } + for (int i = 0; i < 2; i++) { + Device d = new Device(); + d.setName(QUERY_TEST_PREFIX + "hum_" + ts + "_" + i); + d.setType(type2); + client.saveDevice(d, null, null, null, null); + } + + // filter by single device type + EntityDataQuery singleTypeQuery = new EntityDataQuery() + .entityFilter(new DeviceTypeFilter() + .deviceTypes(List.of(type1))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(singleTypeQuery); + assertNotNull(result); + assertEquals(3, result.getTotalElements().intValue()); + for (EntityData entity : result.getData()) { + assertNotNull(entity.getEntityId()); + } + + // filter by multiple device types + EntityDataQuery multiTypeQuery = new EntityDataQuery() + .entityFilter(new DeviceTypeFilter() + .deviceTypes(List.of(type1, type2))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData multiResult = client.findEntityDataByQuery(multiTypeQuery); + assertNotNull(multiResult); + assertEquals(5, multiResult.getTotalElements().intValue()); + + // filter by device type + name filter + EntityDataQuery nameFilterQuery = new EntityDataQuery() + .entityFilter(new DeviceTypeFilter() + .deviceTypes(List.of(type1, type2)) + .deviceNameFilter(QUERY_TEST_PREFIX + "temp_" + ts)) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData nameResult = client.findEntityDataByQuery(nameFilterQuery); + assertNotNull(nameResult); + assertEquals(3, nameResult.getTotalElements().intValue()); + } + + @Test + public void testFindByEntityNameFilter() throws Exception { + long ts = System.currentTimeMillis(); + String prefix = QUERY_TEST_PREFIX + "named_" + ts; + + for (int i = 0; i < 4; i++) { + Device d = new Device(); + d.setName(prefix + "_" + i); + d.setType("default"); + client.saveDevice(d, null, null, null, null); + } + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new EntityNameFilter() + .entityType(EntityType.DEVICE) + .entityNameFilter(prefix)) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(4, result.getTotalElements().intValue()); + assertFalse(result.getHasNext()); + } + + @Test + public void testFindByEntityListFilter() throws Exception { + long ts = System.currentTimeMillis(); + + Device d1 = client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "list_" + ts + "_1").type("default"), null, null, null, null); + Device d2 = client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "list_" + ts + "_2").type("default"), null, null, null, null); + client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "list_" + ts + "_3").type("default"), null, null, null, null); + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new EntityListFilter() + .entityType(EntityType.DEVICE) + .entityList(List.of( + d1.getId().getId().toString(), + d2.getId().getId().toString()))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(2, result.getTotalElements().intValue()); + + List returnedIds = result.getData().stream() + .map(e -> e.getEntityId().getId().toString()) + .collect(Collectors.toList()); + assertTrue(returnedIds.contains(d1.getId().getId().toString())); + assertTrue(returnedIds.contains(d2.getId().getId().toString())); + } + + @Test + public void testFindBySingleEntityFilter() throws Exception { + long ts = System.currentTimeMillis(); + Device device = client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "single_" + ts).type("default"), null, null, null, null); + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new SingleEntityFilter() + .singleEntity(new AliasEntityId() + .id(device.getId().getId()) + .entityType(EntityType.DEVICE))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(1, result.getTotalElements().intValue()); + assertEquals(device.getId().getId().toString(), + result.getData().get(0).getEntityId().getId().toString()); + } + + @Test + public void testFindByAssetTypeFilter() throws Exception { + long ts = System.currentTimeMillis(); + String assetType = "building"; + + for (int i = 0; i < 3; i++) { + Asset a = new Asset(); + a.setName(QUERY_TEST_PREFIX + "asset_" + ts + "_" + i); + a.setType(assetType); + client.saveAsset(a, null, null, null); + } + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new AssetTypeFilter() + .assetTypes(List.of(assetType))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(3, result.getTotalElements().intValue()); + } + + @Test + public void testFindWithKeyFilter() throws Exception { + long ts = System.currentTimeMillis(); + String matchName = QUERY_TEST_PREFIX + "kf_match_" + ts; + String noMatchName = QUERY_TEST_PREFIX + "kf_other_" + ts; + + client.saveDevice(new Device().name(matchName).type("default"), null, null, null, null); + client.saveDevice(new Device().name(noMatchName).type("default"), null, null, null, null); + + KeyFilter nameKeyFilter = new KeyFilter() + .key(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")) + .valueType(EntityKeyValueType.STRING) + .predicate(new StringFilterPredicate() + .operation(StringOperation.CONTAINS) + .value(new FilterPredicateValueString().defaultValue("kf_match")) + .ignoreCase(true)); + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new EntityNameFilter() + .entityType(EntityType.DEVICE) + .entityNameFilter(QUERY_TEST_PREFIX + "kf_")) + .addKeyFiltersItem(nameKeyFilter) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(1, result.getTotalElements().intValue()); + } + + @Test + public void testFindWithPagination() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 5; i++) { + Device d = new Device(); + d.setName(QUERY_TEST_PREFIX + "page_" + ts + "_" + i); + d.setType("default"); + client.saveDevice(d, null, null, null, null); + } + + EntityDataPageLink smallPage = new EntityDataPageLink() + .pageSize(2) + .page(0) + .sortOrder(new EntityDataSortOrder() + .key(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")) + .direction(Direction.ASC)); + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new EntityNameFilter() + .entityType(EntityType.DEVICE) + .entityNameFilter(QUERY_TEST_PREFIX + "page_" + ts)) + .pageLink(smallPage) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + // first page + PageDataEntityData page1 = client.findEntityDataByQuery(query); + assertNotNull(page1); + assertEquals(5, page1.getTotalElements().intValue()); + assertEquals(3, page1.getTotalPages().intValue()); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + // last page + smallPage.setPage(2); + PageDataEntityData lastPage = client.findEntityDataByQuery(query); + assertNotNull(lastPage); + assertEquals(1, lastPage.getData().size()); + assertFalse(lastPage.getHasNext()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/EntityRelationApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/EntityRelationApiClientTest.java new file mode 100644 index 0000000000..1792ba9ff1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/EntityRelationApiClientTest.java @@ -0,0 +1,182 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.Asset; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.EntityRelation; +import org.thingsboard.client.model.EntityRelationInfo; +import org.thingsboard.client.model.EntityRelationsQuery; +import org.thingsboard.client.model.EntitySearchDirection; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.RelationEntityTypeFilter; +import org.thingsboard.client.model.RelationTypeGroup; +import org.thingsboard.client.model.RelationsSearchParameters; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class EntityRelationApiClientTest extends AbstractApiClientTest { + + @Test + public void testEntityRelationLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // create assets and devices to relate + Asset building = new Asset(); + building.setName(TEST_PREFIX + "Building_" + timestamp); + building.setType("building"); + building = client.saveAsset(building, null, null, null); + + Asset floor = new Asset(); + floor.setName(TEST_PREFIX + "Floor_" + timestamp); + floor.setType("floor"); + floor = client.saveAsset(floor, null, null, null); + + Device device1 = new Device(); + device1.setName(TEST_PREFIX + "Sensor_" + timestamp + "_1"); + device1.setType("sensor"); + device1 = client.saveDevice(device1, null, null, null, null); + + Device device2 = new Device(); + device2.setName(TEST_PREFIX + "Sensor_" + timestamp + "_2"); + device2.setType("sensor"); + device2 = client.saveDevice(device2, null, null, null, null); + + Device device3 = new Device(); + device3.setName(TEST_PREFIX + "Sensor_" + timestamp + "_3"); + device3.setType("sensor"); + device3 = client.saveDevice(device3, null, null, null, null); + + // create relations: building -> Contains -> floor, floor -> Contains -> device1/device2/device3 + EntityRelation buildingToFloor = new EntityRelation(); + buildingToFloor.setFrom(building.getId()); + buildingToFloor.setTo(floor.getId()); + buildingToFloor.setType("Contains"); + buildingToFloor.setTypeGroup(RelationTypeGroup.COMMON); + EntityRelation savedRelation = client.saveRelation(buildingToFloor); + assertNotNull(savedRelation); + assertEquals("Contains", savedRelation.getType()); + + client.saveRelation(new EntityRelation() + .from(floor.getId()) + .to(device1.getId()) + .type("Contains") + .typeGroup(RelationTypeGroup.COMMON)); + client.saveRelation(new EntityRelation() + .from(floor.getId()) + .to(device2.getId()) + .type("Contains").typeGroup(RelationTypeGroup.COMMON)); + client.saveRelation(new EntityRelation() + .from(floor.getId()) + .to(device3.getId()) + .type("Manages") + .typeGroup(RelationTypeGroup.COMMON)); + + // get specific relation + EntityRelation fetched = client.getRelation( + building.getId().getId().toString(), "ASSET", + "Contains", + floor.getId().getId().toString(), "ASSET", + RelationTypeGroup.COMMON.getValue()); + assertNotNull(fetched); + assertEquals("Contains", fetched.getType()); + + // find all relations from floor + List fromFloor = client.findEntityRelationsByFrom("ASSET", + floor.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(3, fromFloor.size()); + + // find relations from floor with type filter "Contains" + List containsFromFloor = client.findEntityRelationsByFromAndRelationType("ASSET", + floor.getId().getId().toString(), "Contains", RelationTypeGroup.COMMON.getValue()); + assertEquals(2, containsFromFloor.size()); + + // find relations to device1 + List toDevice1 = client.findEntityRelationsByTo("DEVICE", + device1.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(1, toDevice1.size()); + assertEquals("Contains", toDevice1.get(0).getType()); + + // find relations to device3 with type filter "Manages" + List managesToDevice3 = client.findEntityRelationsByToAndRelationType("DEVICE", + device3.getId().getId().toString(), "Manages", RelationTypeGroup.COMMON.getValue()); + assertEquals(1, managesToDevice3.size()); + + // find info by from (includes entity names) + List infoFromFloor = client.findEntityRelationInfosByFrom("ASSET", + floor.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(3, infoFromFloor.size()); + Device finalDevice = device1; + assertTrue(infoFromFloor.stream().anyMatch(info -> + finalDevice.getName().equals(info.getToName()))); + + // find info by to + List infoToDevice2 = client.findEntityRelationInfosByTo("DEVICE", + device2.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(1, infoToDevice2.size()); + assertEquals(floor.getName(), infoToDevice2.get(0).getFromName()); + + // find by query - search from building, direction FROM, max 2 levels + RelationsSearchParameters params = new RelationsSearchParameters(); + params.setRootId(building.getId().getId()); + params.setRootType(EntityType.ASSET); + params.setDirection(EntitySearchDirection.FROM); + params.setRelationTypeGroup(RelationTypeGroup.COMMON); + params.setMaxLevel(2); + + RelationEntityTypeFilter filter = new RelationEntityTypeFilter(); + filter.setRelationType("Contains"); + filter.setEntityTypes(List.of(EntityType.ASSET, EntityType.DEVICE)); + + EntityRelationsQuery query = new EntityRelationsQuery(); + query.setParameters(params); + query.setFilters(List.of(filter)); + + List queryResult = client.findEntityRelationsByQuery(query); + assertTrue(queryResult.size() >= 3); + + // find info by query + List infoQueryResult = client.findEntityRelationInfosByQuery(query); + assertTrue(infoQueryResult.size() >= 3); + + // delete single relation + client.deleteRelation( + floor.getId().getId().toString(), "ASSET", + "Manages", + device3.getId().getId().toString(), "DEVICE", + RelationTypeGroup.COMMON.getValue()); + + // verify deletion + List afterDelete = client.findEntityRelationsByFrom("ASSET", + floor.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(2, afterDelete.size()); + + // delete all relations for building + client.deleteRelations(building.getId().getId().toString(), "ASSET"); + + List afterDeleteAll = client.findEntityRelationsByFrom("ASSET", + building.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(0, afterDeleteAll.size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/EntityViewApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/EntityViewApiClientTest.java new file mode 100644 index 0000000000..60c0e86662 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/EntityViewApiClientTest.java @@ -0,0 +1,267 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.AttributesEntityView; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.EntitySubtype; +import org.thingsboard.client.model.EntityView; +import org.thingsboard.client.model.EntityViewInfo; +import org.thingsboard.client.model.PageDataEntityView; +import org.thingsboard.client.model.PageDataEntityViewInfo; +import org.thingsboard.client.model.TelemetryEntityView; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class EntityViewApiClientTest extends AbstractApiClientTest { + + private static final String EV_PREFIX = "EvTest_"; + + @Test + public void testSaveAndGetEntityView() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + + EntityView ev = new EntityView(); + ev.setName(EV_PREFIX + "save_" + ts); + ev.setType("testType"); + ev.setEntityId(device.getId()); + ev.setKeys(new TelemetryEntityView() + .timeseries(List.of("temperature", "humidity")) + .attributes(new AttributesEntityView() + .cs(List.of("firmware")) + .ss(List.of("active")) + .sh(List.of()))); + ev.setStartTimeMs(1000L); + ev.setEndTimeMs(2000L); + + EntityView saved = client.saveEntityView(ev, null, null, null); + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals(ev.getName(), saved.getName()); + assertEquals("testType", saved.getType()); + assertEquals(device.getId().getId(), saved.getEntityId().getId()); + assertEquals(List.of("temperature", "humidity"), saved.getKeys().getTimeseries()); + assertEquals(1000L, saved.getStartTimeMs().longValue()); + assertEquals(2000L, saved.getEndTimeMs().longValue()); + + // get by id + String evId = saved.getId().getId().toString(); + EntityView fetched = client.getEntityViewById(evId); + assertNotNull(fetched); + assertEquals(saved.getName(), fetched.getName()); + assertEquals(saved.getType(), fetched.getType()); + assertEquals(saved.getEntityId().getId(), fetched.getEntityId().getId()); + } + + @Test + public void testGetEntityViewInfoById() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "info_" + ts, "infoType", device); + + EntityViewInfo info = client.getEntityViewInfoById(saved.getId().getId().toString()); + assertNotNull(info); + assertEquals(saved.getName(), info.getName()); + assertEquals("infoType", info.getType()); + assertNotNull(info.getEntityId()); + } + + @Test + public void testUpdateEntityView() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "update_" + ts, "default", device); + + saved.setName(EV_PREFIX + "updated_" + ts); + saved.setKeys(new TelemetryEntityView() + .timeseries(List.of("temperature", "pressure")) + .attributes(new AttributesEntityView() + .cs(List.of()) + .ss(List.of()) + .sh(List.of()))); + + EntityView updated = client.saveEntityView(saved, null, null, null); + assertEquals(EV_PREFIX + "updated_" + ts, updated.getName()); + assertEquals(List.of("temperature", "pressure"), updated.getKeys().getTimeseries()); + assertEquals(saved.getId().getId(), updated.getId().getId()); + } + + @Test + public void testDeleteEntityView() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "delete_" + ts, "default", device); + + String evId = saved.getId().getId().toString(); + client.getEntityViewById(evId); + + client.deleteEntityView(evId); + + assertReturns404(() -> client.getEntityViewById(evId)); + } + + @Test + public void testGetTenantEntityViews() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + + for (int i = 0; i < 3; i++) { + createEntityView(EV_PREFIX + "tenant_" + ts + "_" + i, "tenantViewType", device); + } + + PageDataEntityView page = client.getTenantEntityViews(100, 0, null, EV_PREFIX + "tenant_" + ts, null, null); + assertNotNull(page); + assertEquals(3, page.getTotalElements().intValue()); + for (EntityView ev : page.getData()) { + assertTrue(ev.getName().startsWith(EV_PREFIX + "tenant_" + ts)); + } + } + + @Test + public void testGetTenantEntityViewInfos() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + createEntityView(EV_PREFIX + "tinfo_" + ts, "default", device); + + PageDataEntityViewInfo page = client.getTenantEntityViewInfos(100, 0, null, EV_PREFIX + "tinfo_" + ts, null, null); + assertNotNull(page); + assertEquals(1, page.getTotalElements().intValue()); + assertEquals(EV_PREFIX + "tinfo_" + ts, page.getData().get(0).getName()); + } + + @Test + public void testAssignAndUnassignEntityViewToCustomer() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "assign_" + ts, "default", device); + + String evId = saved.getId().getId().toString(); + String customerId = savedClientCustomer.getId().getId().toString(); + + // assign to customer + EntityView assigned = client.assignEntityViewToCustomer(customerId, evId); + assertNotNull(assigned); + assertEquals(savedClientCustomer.getId().getId(), assigned.getCustomerId().getId()); + + // verify in customer entity views + PageDataEntityView customerViews = client.getCustomerEntityViews( + customerId, 100, 0, null, EV_PREFIX + "assign_" + ts, null, null); + assertEquals(1, customerViews.getTotalElements().intValue()); + assertEquals(saved.getName(), customerViews.getData().get(0).getName()); + + // unassign from customer + EntityView unassigned = client.unassignEntityViewFromCustomer(evId); + assertNotNull(unassigned); + + PageDataEntityView afterUnassign = client.getCustomerEntityViews( + customerId, 100, 0, null, EV_PREFIX + "assign_" + ts, null, null); + assertEquals(0, afterUnassign.getTotalElements().intValue()); + } + + @Test + public void testGetCustomerEntityViewInfos() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "cinfo_" + ts, "default", device); + + String evId = saved.getId().getId().toString(); + String customerId = savedClientCustomer.getId().getId().toString(); + + client.assignEntityViewToCustomer(customerId, evId); + + PageDataEntityViewInfo infos = client.getCustomerEntityViewInfos( + customerId, 100, 0, null, EV_PREFIX + "cinfo_" + ts, null, null); + assertNotNull(infos); + assertEquals(1, infos.getTotalElements().intValue()); + assertEquals(saved.getName(), infos.getData().get(0).getName()); + } + + @Test + public void testGetEntityViewTypes() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + createEntityView(EV_PREFIX + "types_" + ts, "uniqueEvType_" + ts, device); + + List types = client.getEntityViewTypes(); + assertNotNull(types); + assertFalse(types.isEmpty()); + + List typeNames = types.stream() + .map(EntitySubtype::getType) + .collect(Collectors.toList()); + assertTrue(typeNames.contains("uniqueEvType_" + ts)); + } + + @Test + public void testGetEntityViewById_notFound() { + String nonExistentId = UUID.randomUUID().toString(); + assertReturns404(() -> client.getEntityViewById(nonExistentId)); + } + + @Test + public void testGetTenantEntityViewsPagination() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + + for (int i = 0; i < 5; i++) { + createEntityView(EV_PREFIX + "paged_" + ts + "_" + i, "default", device); + } + + PageDataEntityView page1 = client.getTenantEntityViews(2, 0, null, EV_PREFIX + "paged_" + ts, null, null); + assertNotNull(page1); + assertEquals(5, page1.getTotalElements().intValue()); + assertEquals(3, page1.getTotalPages().intValue()); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + PageDataEntityView lastPage = client.getTenantEntityViews(2, 2, null, EV_PREFIX + "paged_" + ts, null, null); + assertEquals(1, lastPage.getData().size()); + assertFalse(lastPage.getHasNext()); + } + + private Device createTestDevice(String suffix) throws Exception { + Device device = new Device(); + device.setName(EV_PREFIX + "device_" + suffix); + device.setType("default"); + return client.saveDevice(device, null, null, null, null); + } + + private EntityView createEntityView(String name, String type, Device device) throws Exception { + EntityView ev = new EntityView(); + ev.setName(name); + ev.setType(type); + ev.setEntityId(device.getId()); + ev.setKeys(new TelemetryEntityView() + .timeseries(List.of("temperature")) + .attributes(new AttributesEntityView() + .cs(List.of()) + .ss(List.of()) + .sh(List.of()))); + return client.saveEntityView(ev, null, null, null); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/MobileAppApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/MobileAppApiClientTest.java new file mode 100644 index 0000000000..fd448eaee1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/MobileAppApiClientTest.java @@ -0,0 +1,156 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.MobileApp; +import org.thingsboard.client.model.MobileAppBundle; +import org.thingsboard.client.model.MobileAppBundleInfo; +import org.thingsboard.client.model.MobileAppStatus; +import org.thingsboard.client.model.PageDataMobileApp; +import org.thingsboard.client.model.PageDataMobileAppBundleInfo; +import org.thingsboard.client.model.PlatformType; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class MobileAppApiClientTest extends AbstractApiClientTest { + + @Test + public void testMobileAppLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdApps = new ArrayList<>(); + + // create 3 Android apps + for (int i = 0; i < 3; i++) { + MobileApp app = new MobileApp(); + app.setPkgName("com.test.android." + timestamp + "." + i); + app.setTitle(TEST_PREFIX + "AndroidApp_" + timestamp + "_" + i); + app.setAppSecret("secret_android_" + timestamp + "_" + i); + app.setPlatformType(PlatformType.ANDROID); + app.setStatus(MobileAppStatus.DRAFT); + + MobileApp created = client.saveMobileApp(app); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(app.getPkgName(), created.getPkgName()); + assertEquals(PlatformType.ANDROID, created.getPlatformType()); + assertEquals(MobileAppStatus.DRAFT, created.getStatus()); + + createdApps.add(created); + } + + // create 2 iOS apps + for (int i = 0; i < 2; i++) { + MobileApp app = new MobileApp(); + app.setPkgName("com.test.ios." + timestamp + "." + i); + app.setTitle(TEST_PREFIX + "IosApp_" + timestamp + "_" + i); + app.setAppSecret("secret_ios_" + timestamp + "_" + i); + app.setPlatformType(PlatformType.IOS); + app.setStatus(MobileAppStatus.DRAFT); + + MobileApp created = client.saveMobileApp(app); + assertNotNull(created); + createdApps.add(created); + } + + // list all tenant mobile apps + PageDataMobileApp allApps = client.getTenantMobileApps(100, 0, null, + null, null, null); + assertNotNull(allApps); + assertEquals(5, allApps.getData().size()); + + // list with platform type filter + PageDataMobileApp androidApps = client.getTenantMobileApps(100, 0, PlatformType.ANDROID, + null, null, null); + assertEquals(3, androidApps.getData().size()); + + PageDataMobileApp iosApps = client.getTenantMobileApps(100, 0, PlatformType.IOS, + null, null, null); + assertEquals(2, iosApps.getData().size()); + + // get mobile app by id + MobileApp searchApp = createdApps.get(1); + MobileApp fetchedApp = client.getMobileAppById(searchApp.getId().getId()); + assertEquals(searchApp.getPkgName(), fetchedApp.getPkgName()); + assertEquals(searchApp.getTitle(), fetchedApp.getTitle()); + assertEquals(searchApp.getPlatformType(), fetchedApp.getPlatformType()); + + // update mobile app + MobileApp appToUpdate = createdApps.get(2); + appToUpdate.setTitle(appToUpdate.getTitle() + "_updated"); + MobileApp updatedApp = client.saveMobileApp(appToUpdate); + assertEquals(appToUpdate.getTitle(), updatedApp.getTitle()); + + // create mobile app bundle with android and ios apps + MobileAppBundle bundle = new MobileAppBundle(); + bundle.setTitle(TEST_PREFIX + "Bundle_" + timestamp); + bundle.setDescription("Test bundle"); + bundle.setAndroidAppId(createdApps.get(0).getId()); + bundle.setIosAppId(createdApps.get(3).getId()); + bundle.setOauth2Enabled(false); + + MobileAppBundle savedBundle = client.saveMobileAppBundle(bundle, null); + assertNotNull(savedBundle); + assertNotNull(savedBundle.getId()); + assertEquals(bundle.getTitle(), savedBundle.getTitle()); + + // get bundle info by id + MobileAppBundleInfo bundleInfo = client.getMobileAppBundleInfoById(savedBundle.getId().getId()); + assertEquals(savedBundle.getTitle(), bundleInfo.getTitle()); + assertEquals("Test bundle", bundleInfo.getDescription()); + assertNotNull(bundleInfo.getAndroidPkgName()); + assertNotNull(bundleInfo.getIosPkgName()); + + // list tenant bundles + PageDataMobileAppBundleInfo bundles = client.getTenantMobileAppBundleInfos(100, 0, + TEST_PREFIX + "Bundle_" + timestamp, null, null); + assertEquals(1, bundles.getData().size()); + + // update bundle + savedBundle.setDescription("Updated description"); + MobileAppBundle updatedBundle = client.saveMobileAppBundle(savedBundle, null); + assertEquals("Updated description", updatedBundle.getDescription()); + + // delete bundle + client.deleteMobileAppBundle(savedBundle.getId().getId()); + + // verify bundle deletion + assertReturns404(() -> + client.getMobileAppBundleInfoById(savedBundle.getId().getId()) + ); + + // delete mobile app + UUID appToDeleteId = createdApps.get(0).getId().getId(); + client.deleteMobileApp(appToDeleteId); + + // verify app deletion + assertReturns404(() -> + client.getMobileAppById(appToDeleteId) + ); + + PageDataMobileApp appsAfterDelete = client.getTenantMobileApps(100, 0, null, + null, null, null); + assertEquals(4, appsAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/NotificationApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/NotificationApiClientTest.java new file mode 100644 index 0000000000..026b8784ef --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/NotificationApiClientTest.java @@ -0,0 +1,278 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.EntityActionNotificationRuleTriggerConfig; +import org.thingsboard.client.model.EntityActionRecipientsConfig; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.NotificationDeliveryMethod; +import org.thingsboard.client.model.NotificationRequest; +import org.thingsboard.client.model.NotificationRequestInfo; +import org.thingsboard.client.model.NotificationRule; +import org.thingsboard.client.model.NotificationRuleInfo; +import org.thingsboard.client.model.NotificationRuleTriggerType; +import org.thingsboard.client.model.NotificationSettings; +import org.thingsboard.client.model.NotificationTarget; +import org.thingsboard.client.model.NotificationTemplate; +import org.thingsboard.client.model.NotificationTemplateConfig; +import org.thingsboard.client.model.NotificationType; +import org.thingsboard.client.model.PageDataNotification; +import org.thingsboard.client.model.PageDataNotificationRequestInfo; +import org.thingsboard.client.model.PageDataNotificationRuleInfo; +import org.thingsboard.client.model.PageDataNotificationTarget; +import org.thingsboard.client.model.PageDataNotificationTemplate; +import org.thingsboard.client.model.PlatformUsersNotificationTargetConfig; +import org.thingsboard.client.model.TenantAdministratorsFilter; +import org.thingsboard.client.model.WebDeliveryMethodNotificationTemplate; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class NotificationApiClientTest extends AbstractApiClientTest { + + @Test + public void testNotificationLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // === 1. Notification Target CRUD === + + // Create target + TenantAdministratorsFilter usersFilter = new TenantAdministratorsFilter(); + PlatformUsersNotificationTargetConfig targetConfig = + new PlatformUsersNotificationTargetConfig().usersFilter(usersFilter); + NotificationTarget target = + new NotificationTarget() + .name("Test Target " + timestamp) + ._configuration(targetConfig); + + NotificationTarget savedTarget = client.saveNotificationTarget(target); + assertNotNull(savedTarget); + assertNotNull(savedTarget.getId()); + assertEquals("Test Target " + timestamp, savedTarget.getName()); + + // Get target by ID + NotificationTarget fetchedTarget = + client.getNotificationTargetById(savedTarget.getId().getId()); + assertEquals(savedTarget.getName(), fetchedTarget.getName()); + + // List targets + PageDataNotificationTarget targetsPage = + client.getNotificationTargets(100, 0, null, null, null); + assertNotNull(targetsPage); + assertNotNull(targetsPage.getData()); + assertTrue( + targetsPage.getData().stream() + .anyMatch(t -> t.getName().equals(savedTarget.getName()))); + + // Update target + savedTarget.setName("Updated Target " + timestamp); + NotificationTarget updatedTarget = client.saveNotificationTarget(savedTarget); + assertEquals("Updated Target " + timestamp, updatedTarget.getName()); + + // === 2. Notification Template CRUD === + + // Create template + WebDeliveryMethodNotificationTemplate webTemplate = + new WebDeliveryMethodNotificationTemplate() + .subject("Test Subject") + .body("Test notification body") + .enabled(true); + NotificationTemplateConfig templateConfig = + new NotificationTemplateConfig() + .putDeliveryMethodsTemplatesItem("WEB", webTemplate); + NotificationTemplate template = + new NotificationTemplate() + .name("Test Template " + timestamp) + .notificationType(NotificationType.GENERAL) + ._configuration(templateConfig); + + NotificationTemplate savedTemplate = client.saveNotificationTemplate(template); + assertNotNull(savedTemplate); + assertNotNull(savedTemplate.getId()); + assertEquals("Test Template " + timestamp, savedTemplate.getName()); + + // Get template by ID + NotificationTemplate fetchedTemplate = + client.getNotificationTemplateById(savedTemplate.getId().getId()); + assertEquals(savedTemplate.getName(), fetchedTemplate.getName()); + assertEquals(NotificationType.GENERAL, fetchedTemplate.getNotificationType()); + + // List templates + PageDataNotificationTemplate templatesPage = + client.getNotificationTemplates(100, 0, null, null, null, null); + assertNotNull(templatesPage); + assertTrue( + templatesPage.getData().stream() + .anyMatch(t -> t.getName().equals(savedTemplate.getName()))); + + // Update template + savedTemplate.setName("Updated Template " + timestamp); + NotificationTemplate updatedTemplate = client.saveNotificationTemplate(savedTemplate); + assertEquals("Updated Template " + timestamp, updatedTemplate.getName()); + + // === 3. Send notification & read notifications === + + // Send notification request + NotificationRequest request = + new NotificationRequest() + .targets(List.of(savedTarget.getId().getId())) + .templateId(savedTemplate.getId()); + NotificationRequest sentRequest = client.createNotificationRequest(request); + assertNotNull(sentRequest); + assertNotNull(sentRequest.getId()); + + // Get request by ID + NotificationRequestInfo fetchedRequest = + client.getNotificationRequestById(sentRequest.getId().getId()); + assertNotNull(fetchedRequest); + + // List requests + PageDataNotificationRequestInfo requestsPage = + client.getNotificationRequests(100, 0, null, null, null); + assertNotNull(requestsPage); + assertFalse(requestsPage.getData().isEmpty()); + + // Get notifications for current user + PageDataNotification notificationsPage = + client.getNotifications(100, 0, null, null, null, null, null); + assertNotNull(notificationsPage); + assertFalse(notificationsPage.getData().isEmpty()); + + // Get unread count + Integer unreadCount = client.getUnreadNotificationsCount("WEB"); + assertNotNull(unreadCount); + assertTrue("Expected at least one unread notification", unreadCount > 0); + + // Mark single notification as read + client.markNotificationAsRead( + notificationsPage.getData().get(0).getId().getId()); + + // Mark all as read + client.markAllNotificationsAsRead(null); + Integer unreadAfterMarkAll = client.getUnreadNotificationsCount(null); + assertEquals("Expected no unread notifications after marking all as read", 0, unreadAfterMarkAll.intValue()); + + // === 4. Notification Settings === + + NotificationSettings settings = client.getNotificationSettings(); + assertNotNull(settings); + + List deliveryMethods = client.getAvailableDeliveryMethods(); + assertNotNull(deliveryMethods); + assertTrue(deliveryMethods.contains(NotificationDeliveryMethod.WEB)); + + // === 5. Cleanup === + + // Delete notification request + client.deleteNotificationRequest(sentRequest.getId().getId()); + assertReturns404(() -> client.getNotificationRequestById(sentRequest.getId().getId())); + + // Delete template + client.deleteNotificationTemplateById(savedTemplate.getId().getId()); + assertReturns404(() -> client.getNotificationTemplateById(savedTemplate.getId().getId())); + + // Delete target + client.deleteNotificationTargetById(savedTarget.getId().getId()); + assertReturns404(() -> client.getNotificationTargetById(savedTarget.getId().getId())); + } + + @Test + public void testNotificationRuleLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // Create a target for the rule recipients + TenantAdministratorsFilter usersFilter = new TenantAdministratorsFilter(); + PlatformUsersNotificationTargetConfig targetConfig = + new PlatformUsersNotificationTargetConfig().usersFilter(usersFilter); + NotificationTarget target = + new NotificationTarget() + .name("Rule Test Target " + timestamp) + ._configuration(targetConfig); + NotificationTarget savedTarget = client.saveNotificationTarget(target); + + // Create a template of type ENTITY_ACTION + WebDeliveryMethodNotificationTemplate webTemplate = + new WebDeliveryMethodNotificationTemplate() + .subject("Entity action: ${entityType}") + .body("Entity ${entityName} was ${actionType}") + .enabled(true); + NotificationTemplateConfig templateConfig = + new NotificationTemplateConfig() + .putDeliveryMethodsTemplatesItem("WEB", webTemplate); + NotificationTemplate template = + new NotificationTemplate() + .name("Rule Test Template " + timestamp) + .notificationType(NotificationType.ENTITY_ACTION) + ._configuration(templateConfig); + NotificationTemplate savedTemplate = client.saveNotificationTemplate(template); + + // Build trigger config: fire on DEVICE create/update + EntityActionNotificationRuleTriggerConfig triggerConfig = + new EntityActionNotificationRuleTriggerConfig() + .addEntityTypesItem(EntityType.DEVICE) + .created(true) + .updated(true) + .deleted(false); + + // Build recipients config + EntityActionRecipientsConfig recipientsConfig = new EntityActionRecipientsConfig() + .addTargetsItem(savedTarget.getId().getId()); + + // saveNotificationRule - create + NotificationRule rule = new NotificationRule() + .name("Test Rule " + timestamp) + .enabled(true) + .templateId(savedTemplate.getId()) + .triggerType(NotificationRuleTriggerType.ENTITY_ACTION) + .triggerConfig(triggerConfig) + .recipientsConfig(recipientsConfig); + + NotificationRule savedRule = client.saveNotificationRule(rule); + assertNotNull(savedRule); + assertNotNull(savedRule.getId()); + assertEquals("Test Rule " + timestamp, savedRule.getName()); + assertEquals(NotificationRuleTriggerType.ENTITY_ACTION, savedRule.getTriggerType()); + assertEquals(Boolean.TRUE, savedRule.getEnabled()); + + // getNotificationRuleById + NotificationRuleInfo fetchedRule = client.getNotificationRuleById(savedRule.getId().getId()); + assertNotNull(fetchedRule); + assertEquals(savedRule.getName(), fetchedRule.getName()); + assertEquals(NotificationRuleTriggerType.ENTITY_ACTION, fetchedRule.getTriggerType()); + + // getNotificationRules - verify it appears in the list + PageDataNotificationRuleInfo rulesPage = client.getNotificationRules(100, 0, null, null, null); + assertNotNull(rulesPage); + assertTrue(rulesPage.getData().stream() + .anyMatch(r -> r.getId().getId().equals(savedRule.getId().getId()))); + + // deleteNotificationRule + client.deleteNotificationRule(savedRule.getId().getId()); + assertReturns404(() -> client.getNotificationRuleById(savedRule.getId().getId())); + + // Cleanup + client.deleteNotificationTemplateById(savedTemplate.getId().getId()); + client.deleteNotificationTargetById(savedTarget.getId().getId()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java new file mode 100644 index 0000000000..2099f405f0 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java @@ -0,0 +1,138 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.MapperType; +import org.thingsboard.client.model.OAuth2BasicMapperConfig; +import org.thingsboard.client.model.OAuth2Client; +import org.thingsboard.client.model.OAuth2ClientInfo; +import org.thingsboard.client.model.OAuth2MapperConfig; +import org.thingsboard.client.model.PageDataOAuth2ClientInfo; +import org.thingsboard.client.model.PlatformType; +import org.thingsboard.client.model.TenantNameStrategyType; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class Oauth2ApiClientTest extends AbstractApiClientTest { + + private OAuth2Client createOAuth2Client(String title, String clientId, String clientSecret) { + OAuth2BasicMapperConfig basicConfig = new OAuth2BasicMapperConfig(); + basicConfig.setEmailAttributeKey("email"); + basicConfig.setFirstNameAttributeKey("given_name"); + basicConfig.setLastNameAttributeKey("family_name"); + basicConfig.setTenantNameStrategy(TenantNameStrategyType.DOMAIN); + + OAuth2MapperConfig mapperConfig = new OAuth2MapperConfig(); + mapperConfig.setType(MapperType.BASIC); + mapperConfig.setAllowUserCreation(true); + mapperConfig.setActivateUser(false); + mapperConfig.setBasic(basicConfig); + + OAuth2Client oAuth2Client = new OAuth2Client(); + oAuth2Client.setTitle(title); + oAuth2Client.setClientId(clientId); + oAuth2Client.setClientSecret(clientSecret); + oAuth2Client.setAuthorizationUri("https://accounts.google.com/o/oauth2/v2/auth"); + oAuth2Client.setAccessTokenUri("https://oauth2.googleapis.com/token"); + oAuth2Client.setScope(List.of("openid", "email", "profile")); + oAuth2Client.setUserInfoUri("https://openidconnect.googleapis.com/v1/userinfo"); + oAuth2Client.setUserNameAttributeName("email"); + oAuth2Client.setClientAuthenticationMethod("POST"); + oAuth2Client.setLoginButtonLabel(title); + oAuth2Client.setMapperConfig(mapperConfig); + oAuth2Client.setPlatforms(List.of(PlatformType.WEB)); + + return oAuth2Client; + } + + @Test + public void testOAuth2ClientLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdClients = new ArrayList<>(); + + // create 5 OAuth2 clients + for (int i = 0; i < 5; i++) { + String title = TEST_PREFIX + "OAuth2_" + timestamp + "_" + i; + OAuth2Client oAuth2Client = createOAuth2Client(title, + "client_id_" + timestamp + "_" + i, + "client_secret_" + timestamp + "_" + i); + + OAuth2Client created = client.saveOAuth2Client(oAuth2Client); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(title, created.getTitle()); + assertEquals("POST", created.getClientAuthenticationMethod()); + assertNotNull(created.getMapperConfig()); + assertEquals(MapperType.BASIC, created.getMapperConfig().getType()); + + createdClients.add(created); + } + + // list tenant OAuth2 client infos + PageDataOAuth2ClientInfo clientInfos = client.findOAuth2ClientInfos(100, 0, + TEST_PREFIX + "OAuth2_" + timestamp, null, null); + assertNotNull(clientInfos); + assertEquals(5, clientInfos.getData().size()); + + // get OAuth2 client by id + OAuth2Client searchClient = createdClients.get(2); + OAuth2Client fetchedClient = client.getOAuth2ClientById(searchClient.getId().getId()); + assertEquals(searchClient.getTitle(), fetchedClient.getTitle()); + assertEquals(searchClient.getClientId(), fetchedClient.getClientId()); + assertEquals(searchClient.getAuthorizationUri(), fetchedClient.getAuthorizationUri()); + assertEquals(3, fetchedClient.getScope().size()); + + // fetch client infos by ids + List idsToFetch = List.of( + createdClients.get(0).getId().getId().toString(), + createdClients.get(1).getId().getId().toString() + ); + List fetchedInfos = client.findTenantOAuth2ClientInfosByIds(idsToFetch); + assertEquals(2, fetchedInfos.size()); + + // update OAuth2 client + OAuth2Client clientToUpdate = client.getOAuth2ClientById(createdClients.get(3).getId().getId()); + clientToUpdate.setTitle(clientToUpdate.getTitle() + "_updated"); + clientToUpdate.setLoginButtonLabel("Updated Login"); + clientToUpdate.setPlatforms(List.of(PlatformType.WEB, PlatformType.ANDROID)); + OAuth2Client updatedClient = client.saveOAuth2Client(clientToUpdate); + assertEquals(clientToUpdate.getTitle(), updatedClient.getTitle()); + assertEquals("Updated Login", updatedClient.getLoginButtonLabel()); + assertEquals(2, updatedClient.getPlatforms().size()); + + // delete OAuth2 client + UUID clientToDeleteId = createdClients.get(0).getId().getId(); + client.deleteOauth2Client(clientToDeleteId); + + // verify deletion + assertReturns404(() -> + client.getOAuth2ClientById(clientToDeleteId) + ); + + 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/client/OtaPackageApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/OtaPackageApiClientTest.java new file mode 100644 index 0000000000..c71f2cb2e3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/OtaPackageApiClientTest.java @@ -0,0 +1,261 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.ChecksumAlgorithm; +import org.thingsboard.client.model.DeviceProfileId; +import org.thingsboard.client.model.DeviceProfileInfo; +import org.thingsboard.client.model.OtaPackage; +import org.thingsboard.client.model.OtaPackageInfo; +import org.thingsboard.client.model.OtaPackageType; +import org.thingsboard.client.model.PageDataOtaPackageInfo; +import org.thingsboard.client.model.SaveOtaPackageInfoRequest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.io.File; +import java.io.FileWriter; +import java.nio.file.Files; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class OtaPackageApiClientTest extends AbstractApiClientTest { + + private static final String OTA_PREFIX = "OtaTest_"; + + private DeviceProfileId getDefaultDeviceProfileId() throws Exception { + DeviceProfileInfo profileInfo = client.getDefaultDeviceProfileInfo(); + return (DeviceProfileId) profileInfo.getId(); + } + + private SaveOtaPackageInfoRequest buildOtaPackageInfoRequest( + String title, String version, OtaPackageType type, + DeviceProfileId deviceProfileId, boolean usesUrl, String url) { + SaveOtaPackageInfoRequest request = new SaveOtaPackageInfoRequest(); + request.setTitle(title); + request.setType(type); + request.setUrl(url); + request.setVersion(version); + request.setDeviceProfileId(deviceProfileId); + return request; + } + + private OtaPackageInfo createFirmwareInfo(String suffix) throws Exception { + DeviceProfileId profileId = getDefaultDeviceProfileId(); + SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest( + OTA_PREFIX + suffix, "1.0." + System.currentTimeMillis(), + OtaPackageType.FIRMWARE, profileId, false, null); + return client.saveOtaPackageInfo(request); + } + + private OtaPackageInfo createFirmwareWithUrl(String suffix) throws Exception { + DeviceProfileId profileId = getDefaultDeviceProfileId(); + SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest( + OTA_PREFIX + suffix, "1.0." + System.currentTimeMillis(), + OtaPackageType.FIRMWARE, profileId, true, "https://example.com/firmware.bin"); + return client.saveOtaPackageInfo(request); + } + + @Test + public void testSaveAndGetOtaPackageInfo() throws Exception { + long ts = System.currentTimeMillis(); + DeviceProfileId profileId = getDefaultDeviceProfileId(); + String title = OTA_PREFIX + "save_" + ts; + String version = "1.0." + ts; + + SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest( + title, version, OtaPackageType.FIRMWARE, profileId, true, "https://example.com/fw.bin"); + + OtaPackageInfo saved = client.saveOtaPackageInfo(request); + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals(title, saved.getTitle()); + assertEquals(version, saved.getVersion()); + assertEquals(OtaPackageType.FIRMWARE, saved.getType()); + assertTrue(saved.getUrl().contains("example.com")); + + // get info by id + String pkgId = saved.getId().getId().toString(); + OtaPackageInfo fetched = client.getOtaPackageInfoById(pkgId); + assertNotNull(fetched); + assertEquals(title, fetched.getTitle()); + assertEquals(version, fetched.getVersion()); + } + + @Test + public void testGetOtaPackageById() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo saved = createFirmwareWithUrl("getbyid_" + ts); + + OtaPackage fullPkg = client.getOtaPackageById(saved.getId().getId().toString()); + assertNotNull(fullPkg); + assertEquals(saved.getTitle(), fullPkg.getTitle()); + assertEquals(saved.getVersion(), fullPkg.getVersion()); + } + + @Test + public void testSaveOtaPackageInfoForSoftware() throws Exception { + long ts = System.currentTimeMillis(); + DeviceProfileId profileId = getDefaultDeviceProfileId(); + String title = OTA_PREFIX + "sw_" + ts; + + SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest( + title, "2.0." + ts, OtaPackageType.SOFTWARE, profileId, true, "https://example.com/sw.bin"); + + OtaPackageInfo saved = client.saveOtaPackageInfo(request); + assertNotNull(saved); + assertEquals(OtaPackageType.SOFTWARE, saved.getType()); + assertEquals(title, saved.getTitle()); + } + + @Test + public void testSaveOtaPackageData() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo info = createFirmwareInfo("data_" + ts); + + File tempFile = Files.createTempFile("ota_test_", ".bin").toFile(); + tempFile.deleteOnExit(); + try (FileWriter writer = new FileWriter(tempFile)) { + writer.write("test firmware content " + ts); + } + + OtaPackageInfo updated = client.saveOtaPackageData( + info.getId().getId().toString(), "MD5", tempFile, null); + assertNotNull(updated); + assertTrue(updated.getHasData()); + assertNotNull(updated.getFileName()); + assertNotNull(updated.getDataSize()); + assertTrue(updated.getDataSize() > 0); + assertEquals(ChecksumAlgorithm.MD5, updated.getChecksumAlgorithm()); + } + + @Test + public void testDownloadOtaPackage() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo info = createFirmwareInfo("download_" + ts); + + String content = "downloadable firmware " + ts; + File tempFile = Files.createTempFile("ota_dl_", ".bin").toFile(); + tempFile.deleteOnExit(); + try (FileWriter writer = new FileWriter(tempFile)) { + writer.write(content); + } + + client.saveOtaPackageData(info.getId().getId().toString(), "MD5", tempFile, null); + + File downloaded = client.downloadOtaPackage(info.getId().getId().toString()); + assertNotNull(downloaded); + assertTrue(downloaded.length() > 0); + String downloadedContent = Files.readString(downloaded.toPath()); + assertEquals(content, downloadedContent); + } + + @Test + public void testDeleteOtaPackage() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo saved = createFirmwareWithUrl("delete_" + ts); + + String pkgId = saved.getId().getId().toString(); + client.getOtaPackageInfoById(pkgId); + + client.deleteOtaPackage(pkgId); + + assertReturns404(() -> client.getOtaPackageInfoById(pkgId)); + } + + @Test + public void testGetOtaPackages() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 3; i++) { + createFirmwareWithUrl("list_" + ts + "_" + i); + } + + PageDataOtaPackageInfo page = client.getOtaPackages(100, 0, OTA_PREFIX + "list_" + ts, null, null); + assertNotNull(page); + assertEquals(3, page.getTotalElements().intValue()); + for (OtaPackageInfo pkg : page.getData()) { + assertTrue(pkg.getTitle().startsWith(OTA_PREFIX + "list_" + ts)); + } + } + + @Test + public void testGetOtaPackagesByDeviceProfileAndType() throws Exception { + long ts = System.currentTimeMillis(); + DeviceProfileId profileId = getDefaultDeviceProfileId(); + + createFirmwareWithUrl("byprofile_" + ts + "_0"); + createFirmwareWithUrl("byprofile_" + ts + "_1"); + + PageDataOtaPackageInfo page = client.getOtaPackagesByDeviceProfileAndType( + profileId.getId().toString(), "FIRMWARE", 100, 0, + OTA_PREFIX + "byprofile_" + ts, null, null); + assertNotNull(page); + assertEquals(2, page.getTotalElements().intValue()); + } + + @Test + public void testGetOtaPackageInfoById_notFound() { + String nonExistentId = UUID.randomUUID().toString(); + assertReturns404(() -> client.getOtaPackageInfoById(nonExistentId)); + } + + @Test + public void testGetOtaPackagesPagination() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 5; i++) { + createFirmwareWithUrl("paged_" + ts + "_" + i); + } + + PageDataOtaPackageInfo page1 = client.getOtaPackages(2, 0, OTA_PREFIX + "paged_" + ts, null, null); + assertNotNull(page1); + assertEquals(5, page1.getTotalElements().intValue()); + assertEquals(3, page1.getTotalPages().intValue()); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + PageDataOtaPackageInfo lastPage = client.getOtaPackages(2, 2, OTA_PREFIX + "paged_" + ts, null, null); + assertEquals(1, lastPage.getData().size()); + assertFalse(lastPage.getHasNext()); + } + + @Test + public void testUpdateOtaPackageInfo() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo saved = createFirmwareWithUrl("update_" + ts); + + SaveOtaPackageInfoRequest updateReq = new SaveOtaPackageInfoRequest(); + updateReq.setId(saved.getId()); + updateReq.setTitle(saved.getTitle()); + updateReq.setType(saved.getType()); + updateReq.setVersion(saved.getVersion()); + updateReq.setDeviceProfileId(saved.getDeviceProfileId()); + updateReq.setUrl(saved.getUrl()); + updateReq.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("infoKey", "infoValue")); + + OtaPackageInfo updated = client.saveOtaPackageInfo(updateReq); + assertNotNull(updated); + assertEquals(saved.getId().getId(), updated.getId().getId()); + assertEquals("infoValue", updated.getAdditionalInfo().get("infoKey").asText()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/RpcV1ApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/RpcV1ApiClientTest.java new file mode 100644 index 0000000000..80719052d1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/RpcV1ApiClientTest.java @@ -0,0 +1,72 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.Device; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import static org.junit.Assert.assertEquals; + +@DaoSqlTest +public class RpcV1ApiClientTest extends AbstractApiClientTest { + + private static final String ONE_WAY_BODY = + "{\"method\":\"setGpio\",\"params\":{\"pin\":7,\"value\":1},\"persistent\":true}"; + private static final String TWO_WAY_BODY = + "{\"method\":\"getGpio\",\"params\":{\"pin\":7},\"persistent\":true}"; + + @Test + public void testHandleOneWayDeviceRPCRequest() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + try { + client.handleOneWayDeviceRPCRequestV1(deviceId, ONE_WAY_BODY); + } catch (ApiException e) { + assertEquals("handleOneWayDeviceRPCRequest got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + @Test + public void testHandleTwoWayDeviceRPCRequest() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + try { + client.handleTwoWayDeviceRPCRequestV1(deviceId, TWO_WAY_BODY); + } catch (ApiException e) { + assertEquals("handleTwoWayDeviceRPCRequest got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + private Device createNewDevice(String name) throws ApiException { + Device device = new Device(); + device.setName(name); + device.setType("default"); + return client.saveDevice(device, null, null, null, null); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/RpcV2ApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/RpcV2ApiClientTest.java new file mode 100644 index 0000000000..02b7e0f0ba --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/RpcV2ApiClientTest.java @@ -0,0 +1,133 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.Rpc; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class RpcV2ApiClientTest extends AbstractApiClientTest { + + private static final String PERSISTENT_BODY = + "{\"method\":\"setGpio\",\"params\":{\"pin\":7,\"value\":1},\"persistent\":true}"; + + @Test + public void testHandleOneWayDeviceRPCRequest() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + try { + client.handleOneWayDeviceRPCRequestV2(deviceId, PERSISTENT_BODY); + } catch (ApiException e) { + assertEquals("handleOneWayDeviceRPCRequest1 got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + @Test + public void testHandleTwoWayDeviceRPCRequest() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + try { + client.handleTwoWayDeviceRPCRequestV2(deviceId, PERSISTENT_BODY); + } catch (ApiException e) { + assertEquals("handleTwoWayDeviceRPCRequest1 got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + @Test + public void testGetPersistedRpcAndDeleteRpc() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + String rpcId = postPersistentRpcAndGetId(deviceId); + assertNotNull(rpcId); + + Rpc rpc = client.getPersistedRpc(rpcId); + assertNotNull(rpc); + assertNotNull(rpc.getId()); + + client.deleteRpc(rpcId); + + assertReturns404(() -> client.getPersistedRpc(rpcId)); + + client.deleteDevice(deviceId); + } + + @Test + public void testGetPersistedRpcNotFound() { + assertReturns404(() -> client.getPersistedRpc(UUID.randomUUID().toString())); + } + + @Test + public void testGetPersistedRpcByDevice() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + postPersistentRpcAndGetId(deviceId); + + try { + client.getPersistedRpcByDevice(deviceId, 100, 0, null, null, null, null); + } catch (ApiException e) { + assertEquals("getPersistedRpcByDevice got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + private Device createNewDevice(String name) throws ApiException { + Device device = new Device(); + device.setName(name); + device.setType("default"); + return client.saveDevice(device, null, null, null, null); + } + + private String postPersistentRpcAndGetId(String deviceId) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(getBaseUrl() + "/api/plugins/rpc/oneway/" + deviceId)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + client.getToken()) + .POST(HttpRequest.BodyPublishers.ofString(PERSISTENT_BODY)) + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + return OBJECT_MAPPER.readTree(response.body()).get("rpcId").asText(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/RuleChainApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/RuleChainApiClientTest.java new file mode 100644 index 0000000000..8adcecaf24 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/RuleChainApiClientTest.java @@ -0,0 +1,163 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.NodeConnectionInfo; +import org.thingsboard.client.model.PageDataRuleChain; +import org.thingsboard.client.model.RuleChain; +import org.thingsboard.client.model.RuleChainMetaData; +import org.thingsboard.client.model.RuleChainType; +import org.thingsboard.client.model.RuleNode; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class RuleChainApiClientTest extends AbstractApiClientTest { + + @Test + public void testRuleChainAndNodeLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdChains = new ArrayList<>(); + + // create 5 rule chains + for (int i = 0; i < 5; i++) { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName(TEST_PREFIX + "RuleChain_" + timestamp + "_" + i); + ruleChain.setType(RuleChainType.CORE); + ruleChain.setDebugMode(false); + + RuleChain created = client.saveRuleChain(ruleChain); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(ruleChain.getName(), created.getName()); + assertEquals(RuleChainType.CORE, created.getType()); + + createdChains.add(created); + } + + // list rule chains with text search + PageDataRuleChain filteredChains = client.getRuleChains(100, 0, null, + TEST_PREFIX + "RuleChain_" + timestamp, null, null); + assertNotNull(filteredChains); + assertEquals(5, filteredChains.getData().size()); + + // get rule chain by id + RuleChain searchChain = createdChains.get(2); + RuleChain fetchedChain = client.getRuleChainById(searchChain.getId().getId().toString()); + assertEquals(searchChain.getName(), fetchedChain.getName()); + assertEquals(searchChain.getType(), fetchedChain.getType()); + + // get metadata (initially has default node) + RuleChainMetaData metadata = client.getRuleChainMetaData(searchChain.getId().getId().toString()); + assertNotNull(metadata); + assertEquals(searchChain.getId().getId(), metadata.getRuleChainId().getId()); + + // save metadata with rule nodes and connections + RuleChainMetaData newMetadata = new RuleChainMetaData(metadata.getRuleChainId()); + newMetadata.setVersion(metadata.getVersion()); + newMetadata.setFirstNodeIndex(0); + + // node 0: message type switch + RuleNode switchNode = new RuleNode(); + switchNode.setName("Message Type Switch"); + switchNode.setType("org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode"); + switchNode.setConfiguration(OBJECT_MAPPER.createObjectNode().put("version", 0)); + switchNode.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("layoutX", 200).put("layoutY", 150)); + + // node 1: log node for telemetry + RuleNode logNode = new RuleNode(); + logNode.setName("Log Telemetry"); + logNode.setType("org.thingsboard.rule.engine.action.TbLogNode"); + logNode.setConfiguration(OBJECT_MAPPER.createObjectNode() + .put("scriptLang", "TBEL") + .put("jsScript", "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);") + .put("tbelScript", "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);")); + logNode.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("layoutX", 500).put("layoutY", 100)); + + // node 2: save timeseries + RuleNode saveNode = new RuleNode(); + saveNode.setName("Save Timeseries"); + saveNode.setType("org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode"); + saveNode.setConfiguration(OBJECT_MAPPER.createObjectNode() + .put("defaultTTL", 0) + .put("skipLatestPersistence", false) + .put("useServerTs", false)); + saveNode.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("layoutX", 500).put("layoutY", 250)); + + newMetadata.setNodes(List.of(switchNode, logNode, saveNode)); + + // connection: switch -> log (on "Post telemetry") + NodeConnectionInfo conn1 = new NodeConnectionInfo(); + conn1.setFromIndex(0); + conn1.setToIndex(1); + conn1.setType("Post telemetry"); + + // connection: switch -> save timeseries (on "Post telemetry") + NodeConnectionInfo conn2 = new NodeConnectionInfo(); + conn2.setFromIndex(0); + conn2.setToIndex(2); + conn2.setType("Post telemetry"); + + newMetadata.setConnections(List.of(conn1, conn2)); + newMetadata.setRuleChainConnections(List.of()); + + RuleChainMetaData savedMetadata = client.saveRuleChainMetaData(newMetadata, false); + assertNotNull(savedMetadata); + assertEquals(3, savedMetadata.getNodes().size()); + assertEquals(2, savedMetadata.getConnections().size()); + + // verify saved nodes + RuleChainMetaData fetchedMetadata = client.getRuleChainMetaData(searchChain.getId().getId().toString()); + assertEquals(3, fetchedMetadata.getNodes().size()); + assertTrue(fetchedMetadata.getNodes().stream() + .anyMatch(node -> "Log Telemetry".equals(node.getName()))); + assertTrue(fetchedMetadata.getNodes().stream() + .anyMatch(node -> "Save Timeseries".equals(node.getName()))); + + // get output labels + client.getRuleChainOutputLabels(searchChain.getId().getId().toString()); + + // update rule chain + RuleChain chainToUpdate = createdChains.get(3); + chainToUpdate.setName(chainToUpdate.getName() + "_updated"); + chainToUpdate.setDebugMode(true); + RuleChain updatedChain = client.saveRuleChain(chainToUpdate); + assertEquals(chainToUpdate.getName(), updatedChain.getName()); + assertEquals(true, updatedChain.getDebugMode()); + + // delete rule chain + UUID chainToDeleteId = createdChains.get(0).getId().getId(); + client.deleteRuleChain(chainToDeleteId.toString()); + + // verify deletion + assertReturns404(() -> + client.getRuleChainById(chainToDeleteId.toString()) + ); + + PageDataRuleChain chainsAfterDelete = client.getRuleChains(100, 0, null, + TEST_PREFIX + "RuleChain_" + timestamp, null, null); + assertEquals(4, chainsAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TbImageApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TbImageApiClientTest.java new file mode 100644 index 0000000000..2a06549554 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TbImageApiClientTest.java @@ -0,0 +1,151 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.PageDataTbResourceInfo; +import org.thingsboard.client.model.ResourceExportData; +import org.thingsboard.client.model.TbImageDeleteResult; +import org.thingsboard.client.model.TbResourceInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TbImageApiClientTest extends AbstractApiClientTest { + + private File createTempImage(String name, Color color) throws IOException { + BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(color); + g.fillRect(0, 0, 100, 100); + g.dispose(); + + File tempFile = File.createTempFile(name, ".png"); + tempFile.deleteOnExit(); + ImageIO.write(img, "png", tempFile); + return tempFile; + } + + @Test + public void testImageLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdImages = new ArrayList<>(); + Color[] colors = {Color.RED, Color.GREEN, Color.BLUE, Color.YELLOW, Color.CYAN}; + + // upload 5 images + for (int i = 0; i < 5; i++) { + String title = TEST_PREFIX + "Image_" + timestamp + "_" + i; + File imageFile = createTempImage("test_image_" + i, colors[i]); + + TbResourceInfo uploaded = client.uploadImage(imageFile, title, null); + assertNotNull(uploaded); + assertNotNull(uploaded.getResourceKey()); + assertEquals(title, uploaded.getTitle()); + assertNotNull(uploaded.getLink()); + + createdImages.add(uploaded); + } + + // list images with text search + PageDataTbResourceInfo filteredImages = client.getImages(100, 0, null, false, + TEST_PREFIX + "Image_" + timestamp, null, null); + assertNotNull(filteredImages); + assertEquals(5, filteredImages.getData().size()); + + // get image info by type and key + TbResourceInfo searchImage = createdImages.get(2); + TbResourceInfo fetchedInfo = client.getImageInfo("tenant", searchImage.getResourceKey()); + assertEquals(searchImage.getTitle(), fetchedInfo.getTitle()); + assertEquals(searchImage.getResourceKey(), fetchedInfo.getResourceKey()); + + // download image + File downloadedImage = client.downloadImage("tenant", searchImage.getResourceKey(), null, null); + assertNotNull(downloadedImage); + assertTrue(downloadedImage.exists()); + assertTrue(downloadedImage.length() > 0); + + // download image preview + File preview = client.downloadImagePreview("tenant", searchImage.getResourceKey(), null, null); + assertNotNull(preview); + assertTrue(preview.exists()); + assertTrue(preview.length() > 0); + + // update image file + File updatedImageFile = createTempImage("updated_image", Color.MAGENTA); + TbResourceInfo updatedImage = client.updateImage("tenant", searchImage.getResourceKey(), updatedImageFile); + assertNotNull(updatedImage); + assertEquals(searchImage.getResourceKey(), updatedImage.getResourceKey()); + + // update image info (title) + TbResourceInfo infoToUpdate = client.getImageInfo("tenant", createdImages.get(3).getResourceKey()); + infoToUpdate.setTitle(infoToUpdate.getTitle() + "_updated"); + TbResourceInfo updatedInfo = client.updateImageInfo("tenant", infoToUpdate.getResourceKey(), infoToUpdate); + assertEquals(infoToUpdate.getTitle(), updatedInfo.getTitle()); + + // make image public + TbResourceInfo publicImage = client.updateImagePublicStatus("tenant", + createdImages.get(1).getResourceKey(), true); + assertTrue(publicImage.getPublic()); + assertNotNull(publicImage.getPublicResourceKey()); + assertNotNull(publicImage.getPublicLink()); + + // download public image + File publicDownload = client.downloadPublicImage(publicImage.getPublicResourceKey(), null, null); + assertNotNull(publicDownload); + assertTrue(publicDownload.exists()); + assertTrue(publicDownload.length() > 0); + + // make image private again + TbResourceInfo privateImage = client.updateImagePublicStatus("tenant", + createdImages.get(1).getResourceKey(), false); + assertEquals(false, privateImage.getPublic()); + + // export image + ResourceExportData exportData = client.exportImage("tenant", createdImages.get(4).getResourceKey()); + assertNotNull(exportData); + assertNotNull(exportData.getData()); + assertEquals(createdImages.get(4).getTitle(), exportData.getTitle()); + assertEquals(createdImages.get(4).getResourceKey(), exportData.getResourceKey()); + + // delete image + String keyToDelete = createdImages.get(0).getResourceKey(); + TbImageDeleteResult deleteResult = client.deleteImage("tenant", keyToDelete, false); + assertNotNull(deleteResult); + assertTrue(deleteResult.getSuccess()); + + // verify deletion + assertReturns404(() -> + client.getImageInfo("tenant", keyToDelete) + ); + + PageDataTbResourceInfo imagesAfterDelete = client.getImages(100, 0, null, false, + TEST_PREFIX + "Image_" + timestamp, null, null); + assertEquals(4, imagesAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TbResourceApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TbResourceApiClientTest.java new file mode 100644 index 0000000000..0651ce6cc3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TbResourceApiClientTest.java @@ -0,0 +1,128 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.PageDataTbResourceInfo; +import org.thingsboard.client.model.ResourceType; +import org.thingsboard.client.model.TbResource; +import org.thingsboard.client.model.TbResourceInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.io.File; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TbResourceApiClientTest extends AbstractApiClientTest { + + @Test + public void testResourceLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdResources = new ArrayList<>(); + + // create 5 JS_MODULE resources + for (int i = 0; i < 5; i++) { + TbResource resource = new TbResource(); + resource.setTitle(TEST_PREFIX + "Resource_" + timestamp + "_" + i); + resource.setResourceType(ResourceType.JS_MODULE); + resource.setResourceKey("test_module_" + timestamp + "_" + i + ".js"); + resource.setFileName("test_module_" + timestamp + "_" + i + ".js"); + + String jsContent = "export default function test" + i + "() { return " + i + "; }"; + resource.setData(Base64.getEncoder().encodeToString(jsContent.getBytes())); + + TbResourceInfo created = client.saveResource(resource); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(resource.getTitle(), created.getTitle()); + assertEquals(ResourceType.JS_MODULE, created.getResourceType()); + + createdResources.add(created); + } + + // get tenant resources, check count + PageDataTbResourceInfo tenantResources = client.getTenantResources(100, 0, null, null, null); + assertNotNull(tenantResources); + assertNotNull(tenantResources.getData()); + int initialSize = tenantResources.getData().size(); + assertTrue("Expected at least 5 resources, but got " + initialSize, initialSize >= 5); + + // find with text search + PageDataTbResourceInfo filteredResources = client.getTenantResources(100, 0, + TEST_PREFIX + "Resource_" + timestamp, null, null); + assertEquals(5, filteredResources.getData().size()); + + // get resources with type filter + PageDataTbResourceInfo jsResources = client.getResources(100, 0, + ResourceType.JS_MODULE.getValue(), null, TEST_PREFIX + "Resource_" + timestamp, null, null); + assertEquals(5, jsResources.getData().size()); + + // get resource info by id + TbResourceInfo searchResource = createdResources.get(2); + TbResourceInfo fetchedInfo = client.getResourceInfoById(searchResource.getId().getId().toString()); + assertEquals(searchResource.getTitle(), fetchedInfo.getTitle()); + assertEquals(searchResource.getResourceKey(), fetchedInfo.getResourceKey()); + + // get full resource by id (includes data) + TbResource fullResource = client.getResourceById(searchResource.getId().getId().toString()); + assertNotNull(fullResource); + assertEquals(searchResource.getTitle(), fullResource.getTitle()); + assertNotNull(fullResource.getData()); + + // download resource + File downloadedFile = client.downloadResource(searchResource.getId().getId().toString()); + assertNotNull(downloadedFile); + assertTrue(downloadedFile.exists()); + assertTrue(downloadedFile.length() > 0); + + // get resources by list of ids + List idsToFetch = List.of( + createdResources.get(0).getId().getId().toString(), + createdResources.get(1).getId().getId().toString() + ); + List resourceList = client.getSystemOrTenantResourcesByIds(idsToFetch); + assertEquals(2, resourceList.size()); + + // update resource + TbResource resourceToUpdate = client.getResourceById(createdResources.get(3).getId().getId().toString()); + resourceToUpdate.setTitle(resourceToUpdate.getTitle() + "_updated"); + String updatedContent = "export default function updated() { return 42; }"; + resourceToUpdate.setData(Base64.getEncoder().encodeToString(updatedContent.getBytes())); + TbResourceInfo updatedResource = client.saveResource(resourceToUpdate); + assertEquals(resourceToUpdate.getTitle(), updatedResource.getTitle()); + + // delete resource + UUID resourceToDeleteId = createdResources.get(0).getId().getId(); + client.deleteResource(resourceToDeleteId.toString(), false); + + // verify deletion + assertReturns404(() -> + client.getResourceInfoById(resourceToDeleteId.toString()) + ); + + PageDataTbResourceInfo resourcesAfterDelete = client.getTenantResources(100, 0, + TEST_PREFIX + "Resource_" + timestamp, null, null); + assertEquals(4, resourcesAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TelemetryApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TelemetryApiClientTest.java new file mode 100644 index 0000000000..a2dbe0eda4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TelemetryApiClientTest.java @@ -0,0 +1,150 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.AttributeData; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.TsData; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TelemetryApiClientTest extends AbstractApiClientTest { + + @Test + public void testTelemetryLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // create a device for telemetry operations + Device device = new Device(); + device.setName("TelemetryTestDevice_" + timestamp); + device.setType("default"); + Device createdDevice = client.saveDevice(device, null, null, null, null); + assertNotNull(createdDevice); + + String entityType = "DEVICE"; + String entityId = createdDevice.getId().getId().toString(); + + // save server-side attributes + String serverAttributes = "{\"serverAttr1\": \"value1\", \"serverAttr2\": 42}"; + client.saveEntityAttributesV2(entityType, entityId, "SERVER_SCOPE", serverAttributes); + + // save shared attributes + String sharedAttributes = "{\"sharedAttr1\": \"sharedValue1\", \"sharedAttr2\": true}"; + client.saveEntityAttributesV2(entityType, entityId, "SHARED_SCOPE", sharedAttributes); + + // get attribute keys + List allKeys = client.getAttributeKeys(entityType, entityId); + assertNotNull(allKeys); + assertTrue(allKeys.containsAll(List.of("serverAttr1", "serverAttr2", "sharedAttr1", "sharedAttr2"))); + + // get attribute keys by scope + List serverKeys = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE"); + assertEquals(2 + 1, serverKeys.size()); //active attribute is automatically added to server scope + assertTrue(serverKeys.containsAll(List.of("serverAttr1", "serverAttr2", "active"))); + + // get attributes by scope + List serverAttrs = client.getAttributesByScope(entityType, entityId, "SERVER_SCOPE", "serverAttr1,serverAttr2", null); + assertNotNull(serverAttrs); + assertEquals(2, serverAttrs.size()); + + // get all attributes + List allAttrs = client.getAttributes(entityType, entityId, "serverAttr1,sharedAttr1", null); + assertEquals(2, allAttrs.size()); + assertEquals("value1", allAttrs.stream().filter(attr -> attr.getKey().equals("serverAttr1")).findFirst().orElseThrow().getValue().toString()); + assertEquals("sharedValue1", allAttrs.stream().filter(attr -> attr.getKey().equals("sharedAttr1")).findFirst().orElseThrow().getValue().toString()); + + // save timeseries data + long ts1 = timestamp - 60000; + long ts2 = timestamp - 30000; + long ts3 = timestamp; + String telemetryBody = "{\"ts\":" + ts1 + ",\"values\":{\"temperature\":25.5,\"humidity\":60}}"; + client.saveEntityTelemetry(entityType, entityId, "ANY", telemetryBody); + + String telemetryBody2 = "{\"ts\":" + ts2 + ",\"values\":{\"temperature\":26.0,\"humidity\":58}}"; + client.saveEntityTelemetry(entityType, entityId, "ANY", telemetryBody2); + + String telemetryBody3 = "{\"ts\":" + ts3 + ",\"values\":{\"temperature\":27.1,\"humidity\":55}}"; + client.saveEntityTelemetry(entityType, entityId, "ANY", telemetryBody3); + + // get timeseries keys + List tsKeys = client.getTimeseriesKeys(entityType, entityId); + assertNotNull(tsKeys); + assertEquals(2, tsKeys.size()); + assertTrue(tsKeys.containsAll(List.of("humidity", "temperature"))); + + // get latest timeseries + Map> latestData = client.getLatestTimeseries(entityType, entityId, "temperature,humidity", false, null); + assertNotNull(latestData); + assertNotNull(latestData.get("temperature")); + assertFalse(latestData.get("temperature").isEmpty()); + assertEquals("27.1", latestData.get("temperature").get(0).getValue().toString()); + + // get timeseries history + Map> historyData = client.getTimeseriesHistory( + entityType, entityId, + ts1 - 1000, ts3 + 1000, "temperature", + null, null, null, null, "NONE", "ASC", false, null); + assertNotNull(historyData); + List tempHistory = historyData.get("temperature"); + assertNotNull(tempHistory); + assertEquals(3, tempHistory.size()); + assertEquals("25.5", tempHistory.get(0).getValue().toString()); + assertEquals("27.1", tempHistory.get(2).getValue().toString()); + + // delete timeseries + client.deleteEntityTimeseries(entityType, entityId, "humidity", true, null, null, true, false, null); + + List keysAfterDelete = client.getTimeseriesKeys(entityType, entityId); + assertFalse(keysAfterDelete.contains("humidity")); + + // delete attributes + client.deleteEntityAttributes(entityType, entityId, "SERVER_SCOPE", "serverAttr1", null); + + List serverKeysAfterDelete = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE"); + assertFalse(serverKeysAfterDelete.contains("serverAttr1")); + assertTrue(serverKeysAfterDelete.contains("serverAttr2")); + + // save device attributes using device-specific endpoint + client.saveDeviceAttributes(entityId, "SERVER_SCOPE", "{\"deviceSpecificAttr\": \"test\"}"); + + List deviceKeys = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE"); + assertTrue(deviceKeys.contains("deviceSpecificAttr")); + + // delete device attributes + client.deleteDeviceAttributes(entityId, "SERVER_SCOPE", "deviceSpecificAttr", null); + + List deviceKeysAfterDelete = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE"); + assertFalse(deviceKeysAfterDelete.contains("deviceSpecificAttr")); + + // save telemetry with TTL + String ttlTelemetry = "{\"ts\":" + timestamp + ",\"values\":{\"shortLived\":99}}"; + client.saveEntityTelemetryWithTTL(entityType, entityId, "ANY", 86400L, ttlTelemetry); + + Map> latestWithTtl = client.getLatestTimeseries(entityType, entityId, "shortLived", false, null); + assertNotNull(latestWithTtl.get("shortLived")); + assertEquals("99", latestWithTtl.get("shortLived").get(0).getValue().toString()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TenantApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TenantApiClientTest.java new file mode 100644 index 0000000000..150486eb8c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TenantApiClientTest.java @@ -0,0 +1,119 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.Authority; +import org.thingsboard.client.model.PageDataTenant; +import org.thingsboard.client.model.PageDataUser; +import org.thingsboard.client.model.Tenant; +import org.thingsboard.client.model.User; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class TenantApiClientTest extends AbstractApiClientTest { + + @Test + public void testTenantLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdTenants = new ArrayList<>(); + + // authenticate as sysadmin for tenant management + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // create 20 tenants + for (int i = 0; i < 20; i++) { + Tenant tenant = new Tenant(); + String tenantTitle = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + tenant.setTitle(tenantTitle); + tenant.setEmail("tenant_" + timestamp + "_" + i + "@test.com"); + tenant.setCountry("US"); + tenant.setCity("City" + i); + + Tenant createdTenant = client.saveTenant(tenant); + assertNotNull(createdTenant); + assertNotNull(createdTenant.getId()); + assertEquals(tenantTitle, createdTenant.getTitle()); + + createdTenants.add(createdTenant); + } + + try { + // find all with search text, check count + PageDataTenant filteredTenants = client.getTenants(100, 0, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 tenants matching prefix", 10, filteredTenants.getData().size()); + + // find by id + Tenant searchTenant = createdTenants.get(10); + Tenant fetchedTenant = client.getTenantById(searchTenant.getId().getId().toString()); + assertEquals(searchTenant.getTitle(), fetchedTenant.getTitle()); + assertEquals(searchTenant.getEmail(), fetchedTenant.getEmail()); + + // update tenant + fetchedTenant.setCity("Updated City"); + fetchedTenant.setCountry("DE"); + Tenant updatedTenant = client.saveTenant(fetchedTenant); + assertEquals("Updated City", updatedTenant.getCity()); + assertEquals("DE", updatedTenant.getCountry()); + + // create a tenant admin for one of the tenants and verify listing + Tenant tenantForAdmin = createdTenants.get(0); + User adminUser = new User(); + adminUser.setEmail("tenanttest_admin_" + timestamp + "@test.com"); + adminUser.setAuthority(Authority.TENANT_ADMIN); + adminUser.setTenantId(tenantForAdmin.getId()); + adminUser.setFirstName("TestAdmin"); + User savedAdmin = client.saveUser(adminUser, "false"); + assertNotNull(savedAdmin); + + PageDataUser tenantAdmins = client.getTenantAdmins( + tenantForAdmin.getId().getId().toString(), 100, 0, null, null, null); + assertEquals(1, tenantAdmins.getData().size()); + assertEquals(savedAdmin.getEmail(), tenantAdmins.getData().get(0).getEmail()); + + // delete tenant + UUID tenantToDeleteId = createdTenants.get(0).getId().getId(); + client.deleteTenant(tenantToDeleteId.toString()); + createdTenants.remove(0); + + // verify deletion + PageDataTenant tenantsAfterDelete = client.getTenants(100, 0, TEST_PREFIX_2, null, null); + assertEquals(10, tenantsAfterDelete.getData().size()); + + assertReturns404(() -> + client.getTenantById(tenantToDeleteId.toString()) + ); + } finally { + // clean up all created tenants (deleting tenant cascades to users) + client.login("sysadmin@thingsboard.org", "sysadmin"); + for (Tenant tenant : createdTenants) { + try { + client.deleteTenant(tenant.getId().getId().toString()); + } catch (ApiException ignored) { + } + } + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TenantProfileApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TenantProfileApiClientTest.java new file mode 100644 index 0000000000..68dd5f7c0e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TenantProfileApiClientTest.java @@ -0,0 +1,179 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.DefaultTenantProfileConfiguration; +import org.thingsboard.client.model.EntityInfo; +import org.thingsboard.client.model.PageDataEntityInfo; +import org.thingsboard.client.model.PageDataTenantProfile; +import org.thingsboard.client.model.TenantProfile; +import org.thingsboard.client.model.TenantProfileData; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TenantProfileApiClientTest extends AbstractApiClientTest { + + @Test + public void testTenantProfileLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdProfiles = new ArrayList<>(); + + // authenticate as sysadmin for tenant profile management + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // get initial count (there should be a default profile) + PageDataTenantProfile initialProfiles = client.getTenantProfiles(100, 0, null, null, null); + assertNotNull(initialProfiles); + int initialSize = initialProfiles.getData().size(); + assertTrue("Expected at least 1 default tenant profile", initialSize >= 1); + + // get default tenant profile info + EntityInfo defaultProfileInfo = client.getDefaultTenantProfileInfo(); + assertNotNull(defaultProfileInfo); + assertNotNull(defaultProfileInfo.getName()); + + try { + // create 5 tenant profiles + for (int i = 0; i < 5; i++) { + TenantProfile profile = new TenantProfile(); + profile.setName(TEST_PREFIX + "TenantProfile_" + timestamp + "_" + i); + profile.setDescription("Test tenant profile " + i); + profile.setIsolatedTbRuleEngine(false); + + TenantProfileData profileData = new TenantProfileData(); + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + config.setMaxDevices(100L); + config.setMaxAssets(100L); + config.setMaxCustomers(50L); + config.setMaxUsers(50L); + config.setMaxDashboards(50L); + config.setMaxRuleChains(20L); + config.setMaxDataPointsPerRollingArg(20L); + config.setMaxRelatedEntitiesToReturnPerCfArgument(20); + config.setMaxRelationLevelPerCfArgument(20); + profileData.setConfiguration(config); + profile.setProfileData(profileData); + profile.setDefault(false); + + TenantProfile created = client.saveTenantProfile(profile); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(profile.getName(), created.getName()); + assertEquals(profile.getDescription(), created.getDescription()); + assertFalse(created.getDefault()); + + createdProfiles.add(created); + } + + // find all, check count + PageDataTenantProfile allProfiles = client.getTenantProfiles(100, 0, null, null, null); + assertNotNull(allProfiles); + assertEquals(initialSize + 5, allProfiles.getData().size()); + + // find with text search + PageDataTenantProfile filteredProfiles = client.getTenantProfiles(100, 0, + TEST_PREFIX + "TenantProfile_" + timestamp, null, null); + assertEquals(5, filteredProfiles.getData().size()); + + // get by id + TenantProfile searchProfile = createdProfiles.get(2); + TenantProfile fetchedProfile = client.getTenantProfileById(searchProfile.getId().getId().toString()); + assertEquals(searchProfile.getName(), fetchedProfile.getName()); + assertEquals(searchProfile.getDescription(), fetchedProfile.getDescription()); + + // update tenant profile + fetchedProfile.setDescription("Updated description"); + TenantProfile updatedProfile = client.saveTenantProfile(fetchedProfile); + assertEquals("Updated description", updatedProfile.getDescription()); + assertEquals(fetchedProfile.getName(), updatedProfile.getName()); + + // get tenant profile infos (paginated) + PageDataEntityInfo profileInfos = client.getTenantProfileInfos(100, 0, null, null, null); + assertNotNull(profileInfos); + assertEquals(initialSize + 5, profileInfos.getData().size()); + + // get profiles by list of ids + List idsToFetch = List.of( + createdProfiles.get(0).getId().getId().toString(), + createdProfiles.get(1).getId().getId().toString() + ); + List profileList = client.getTenantProfileList(idsToFetch); + assertEquals(2, profileList.size()); + + // set a profile as default + TenantProfile profileToSetDefault = createdProfiles.get(1); + client.setDefaultTenantProfile(profileToSetDefault.getId().getId().toString()); + EntityInfo defaultTenantProfileInfo = client.getDefaultTenantProfileInfo(); + assertEquals(profileToSetDefault.getName(), defaultTenantProfileInfo.getName()); + + // verify default profile info now points to the new default + EntityInfo newDefaultInfo = client.getDefaultTenantProfileInfo(); + assertEquals(profileToSetDefault.getName(), newDefaultInfo.getName()); + + // restore original default profile + TenantProfile originalDefault = initialProfiles.getData().stream() + .filter(TenantProfile::getDefault) + .findFirst() + .orElseThrow(); + client.setDefaultTenantProfile(originalDefault.getId().getId().toString()); + + // delete tenant profile (cannot delete the default one) + UUID profileToDeleteId = createdProfiles.get(0).getId().getId(); + client.deleteTenantProfile(profileToDeleteId.toString()); + createdProfiles.remove(0); + + // verify deletion + assertReturns404(() -> + client.getTenantProfileById(profileToDeleteId.toString()) + ); + + PageDataTenantProfile profilesAfterDelete = client.getTenantProfiles(100, 0, null, null, null); + assertEquals(initialSize + 4, profilesAfterDelete.getData().size()); + } finally { + // clean up created profiles + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // ensure original default is restored before deleting test profiles + TenantProfile originalDefault = initialProfiles.getData().stream() + .filter(TenantProfile::getDefault) + .findFirst() + .orElseThrow(); + try { + client.setDefaultTenantProfile(originalDefault.getId().getId().toString()); + } catch (ApiException ignored) { + } + + for (TenantProfile profile : createdProfiles) { + try { + client.deleteTenantProfile(profile.getId().getId().toString()); + } catch (ApiException ignored) { + } + } + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TwoFactorAuthApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TwoFactorAuthApiClientTest.java new file mode 100644 index 0000000000..5f371b5d82 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TwoFactorAuthApiClientTest.java @@ -0,0 +1,78 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.AccountTwoFaSettings; +import org.thingsboard.client.model.PlatformTwoFaSettings; +import org.thingsboard.client.model.TotpTwoFaAccountConfig; +import org.thingsboard.client.model.TotpTwoFaProviderConfig; +import org.thingsboard.client.model.TwoFaAccountConfig; +import org.thingsboard.client.model.TwoFaProviderType; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TwoFactorAuthApiClientTest extends AbstractApiClientTest { + + @Test + public void testTwoFactorAuthLifecycle() throws Exception { + // save original platform 2FA settings as sysadmin + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // configure platform 2FA settings with TOTP provider + TotpTwoFaProviderConfig totpProviderConfig = new TotpTwoFaProviderConfig(); + totpProviderConfig.setIssuerName("TestThingsBoard"); + + PlatformTwoFaSettings newSettings = new PlatformTwoFaSettings(); + newSettings.setProviders(List.of(totpProviderConfig)); + newSettings.setMinVerificationCodeSendPeriod(30); + newSettings.setTotalAllowedTimeForVerification(300); + newSettings.setMaxVerificationFailuresBeforeUserLockout(5); + + PlatformTwoFaSettings savedSettings = client.savePlatformTwoFaSettings(newSettings); + assertNotNull(savedSettings); + assertNotNull(savedSettings.getProviders()); + assertFalse(savedSettings.getProviders().isEmpty()); + assertEquals(30, savedSettings.getMinVerificationCodeSendPeriod().intValue()); + assertEquals(300, savedSettings.getTotalAllowedTimeForVerification().intValue()); + + // get available 2FA providers (should include TOTP) + List providerTypes = client.getAvailableTwoFaProviderTypes(); + assertNotNull(providerTypes); + assertTrue(providerTypes.contains(TwoFaProviderType.TOTP)); + + // get account 2FA settings (should be empty initially) + AccountTwoFaSettings accountSettings = client.getAccountTwoFaSettings(); + assertNull(accountSettings); + + // generate TOTP account config + TwoFaAccountConfig generatedConfig = client.generateTwoFaAccountConfig(TwoFaProviderType.TOTP.getValue()); + assertNotNull(generatedConfig); + TotpTwoFaAccountConfig totpConfig = (TotpTwoFaAccountConfig) generatedConfig; + assertNotNull(totpConfig); + assertNotNull(totpConfig.getAuthUrl()); + assertTrue(totpConfig.getAuthUrl().startsWith("otpauth://totp/")); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/UserApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/UserApiClientTest.java new file mode 100644 index 0000000000..154858405f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/UserApiClientTest.java @@ -0,0 +1,135 @@ +/** + * 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.client; + +import org.junit.Test; +import org.thingsboard.client.model.Authority; +import org.thingsboard.client.model.Customer; +import org.thingsboard.client.model.JwtPair; +import org.thingsboard.client.model.PageDataUser; +import org.thingsboard.client.model.User; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class UserApiClientTest extends AbstractApiClientTest { + + @Test + public void testUserLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdUsers = new ArrayList<>(); + + // create 20 tenant admin users + for (int i = 0; i < 20; i++) { + User user = new User(); + String email = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i + "@test.com"; + user.setEmail(email); + user.setAuthority(Authority.TENANT_ADMIN); + user.setTenantId(savedClientTenant.getId()); + user.setFirstName("First" + i); + user.setLastName("Last" + i); + + User createdUser = client.saveUser(user, "false"); + assertNotNull(createdUser); + assertNotNull(createdUser.getId()); + assertEquals(email, createdUser.getEmail()); + assertEquals(Authority.TENANT_ADMIN, createdUser.getAuthority()); + + createdUsers.add(createdUser); + } + + // find all tenant admins, check count (20 created + 1 from setup) + PageDataUser allUsers = client.getUsers(100, 0, null, null, null); + assertNotNull(allUsers); + assertNotNull(allUsers.getData()); + int initialSize = allUsers.getData().size(); + assertEquals("Expected 21 users (20 created + 2 from setup), but got " + initialSize, 22, initialSize); + + // find with search text, check count + PageDataUser filteredUsers = client.getUsers(100, 0, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 users matching prefix", 10, filteredUsers.getData().size()); + + // find by id + User searchUser = createdUsers.get(10); + User fetchedUser = client.getUserById(searchUser.getId().getId().toString()); + assertEquals(searchUser.getEmail(), fetchedUser.getEmail()); + assertEquals(searchUser.getFirstName(), fetchedUser.getFirstName()); + + // update user + fetchedUser.setFirstName("UpdatedFirst"); + fetchedUser.setLastName("UpdatedLast"); + User updatedUser = client.saveUser(fetchedUser, "false"); + assertEquals("UpdatedFirst", updatedUser.getFirstName()); + assertEquals("UpdatedLast", updatedUser.getLastName()); + + // activate user and get token + activateUser(createdUsers.get(0).getId(), "password123", false); + JwtPair userToken = client.getUserToken(createdUsers.get(0).getId().getId().toString()); + assertNotNull(userToken); + assertNotNull(userToken.getToken()); + + // disable user credentials + client.setUserCredentialsEnabled(createdUsers.get(0).getId().getId().toString(), "false"); + + // re-enable user credentials + client.setUserCredentialsEnabled(createdUsers.get(0).getId().getId().toString(), "true"); + + // create customer users and verify listing + Customer customer2 = new Customer(); + customer2.setTitle("User test customer " + timestamp); + customer2.setEmail("usertest_" + timestamp + "@test.com"); + Customer savedCustomer2 = client.saveCustomer(customer2, null, null, null); + + List customerUsers = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + User customerUser = new User(); + customerUser.setEmail("custuser_" + timestamp + "_" + i + "@test.com"); + customerUser.setAuthority(Authority.CUSTOMER_USER); + customerUser.setTenantId(savedClientTenant.getId()); + customerUser.setCustomerId(savedCustomer2.getId()); + customerUser.setFirstName("CustFirst" + i); + customerUser.setLastName("CustLast" + i); + + User created = client.saveUser(customerUser, "false"); + assertNotNull(created); + customerUsers.add(created); + } + + // list customer users + PageDataUser customerUserPage = client.getCustomerUsers( + savedCustomer2.getId().getId().toString(), 100, 0, null, null, null); + assertEquals("Expected 5 customer users", 5, customerUserPage.getData().size()); + + // delete user + UUID userToDeleteId = createdUsers.get(0).getId().getId(); + client.deleteUser(userToDeleteId.toString()); + + // verify deletion + PageDataUser usersAfterDelete = client.getUsers(100, 0, null, null, null); + assertEquals(initialSize + 5 - 1, usersAfterDelete.getData().size()); + + assertReturns404(() -> + client.getUserById(userToDeleteId.toString()) + ); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/WidgetTypeApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/WidgetTypeApiClientTest.java new file mode 100644 index 0000000000..ee3984bb4b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/WidgetTypeApiClientTest.java @@ -0,0 +1,155 @@ +/** + * 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.client; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.Test; +import org.thingsboard.client.model.PageDataWidgetTypeInfo; +import org.thingsboard.client.model.WidgetTypeDetails; +import org.thingsboard.client.model.WidgetTypeInfo; +import org.thingsboard.client.model.WidgetsBundle; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class WidgetTypeApiClientTest extends AbstractApiClientTest { + + private JsonNode createDescriptor(String type) { + return OBJECT_MAPPER.createObjectNode() + .put("type", type) + .put("sizeX", 7.5) + .put("sizeY", 5) + .put("resources", "[]") + .put("templateHtml", "
Test
") + .put("templateCss", ".test-widget { font-size: 14px; }") + .put("controllerScript", "self.onInit = function() {};") + .put("settingsSchema", "{}") + .put("dataKeySettingsSchema", "{}"); + } + + @Test + public void testWidgetTypeLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdWidgetTypes = new ArrayList<>(); + + // create a widgets bundle + WidgetsBundle bundle = new WidgetsBundle(null, null, null, + TEST_PREFIX + "Bundle_" + timestamp, null, false, + "Test bundle description", null, null); + WidgetsBundle savedBundle = client.saveWidgetsBundle(bundle); + assertNotNull(savedBundle); + assertNotNull(savedBundle.getId()); + assertEquals(bundle.getTitle(), savedBundle.getTitle()); + + // create 5 widget types + for (int i = 0; i < 5; i++) { + String name = TEST_PREFIX + "Widget_" + timestamp + "_" + i; + JsonNode descriptor = createDescriptor("latest"); + + WidgetTypeDetails widgetType = new WidgetTypeDetails(null, null, null, name, descriptor); + widgetType.setDescription("Test widget " + i); + widgetType.setDeprecated(false); + widgetType.setTags(List.of("test", "automated")); + + WidgetTypeDetails created = client.saveWidgetType(widgetType, false); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(name, created.getName()); + assertNotNull(created.getFqn()); + + createdWidgetTypes.add(created); + } + + // list widget types with text search (tenant only) + PageDataWidgetTypeInfo filteredTypes = client.getWidgetTypes(100, 0, + TEST_PREFIX + "Widget_" + timestamp, null, null, + true, false, null, null, null); + assertNotNull(filteredTypes); + assertEquals(5, filteredTypes.getData().size()); + + // get widget type details by id + WidgetTypeDetails searchWidget = createdWidgetTypes.get(2); + WidgetTypeDetails fetchedDetails = client.getWidgetTypeById( + searchWidget.getId().getId().toString(), true); + assertEquals(searchWidget.getName(), fetchedDetails.getName()); + assertEquals(searchWidget.getFqn(), fetchedDetails.getFqn()); + assertEquals("Test widget 2", fetchedDetails.getDescription()); + + // get widget type info by id + WidgetTypeInfo fetchedInfo = client.getWidgetTypeInfoById( + searchWidget.getId().getId().toString()); + assertEquals(searchWidget.getName(), fetchedInfo.getName()); + + // add widget types to bundle + List widgetTypeIds = createdWidgetTypes.stream() + .map(wt -> wt.getId().getId().toString()) + .collect(Collectors.toList()); + client.updateWidgetsBundleWidgetTypes(savedBundle.getId().getId().toString(), widgetTypeIds); + + // get bundle widget type fqns + List bundleFqns = client.getBundleWidgetTypeFqns(savedBundle.getId().getId().toString()); + assertEquals(5, bundleFqns.size()); + + // get bundle widget types details + List bundleDetails = client.getBundleWidgetTypesDetails( + savedBundle.getId().getId().toString(), false); + assertEquals(5, bundleDetails.size()); + + // get bundle widget types infos (paginated) + PageDataWidgetTypeInfo bundleInfos = client.getBundleWidgetTypesInfos( + savedBundle.getId().getId().toString(), 100, 0, + null, null, null, null, null, null); + assertEquals(5, bundleInfos.getData().size()); + + // update widget type + WidgetTypeDetails widgetToUpdate = client.getWidgetTypeById( + createdWidgetTypes.get(3).getId().getId().toString(), true); + widgetToUpdate.setDescription("Updated description"); + widgetToUpdate.setDeprecated(true); + widgetToUpdate.setTags(List.of("test", "updated")); + WidgetTypeDetails updatedWidget = client.saveWidgetType(widgetToUpdate, false); + assertEquals("Updated description", updatedWidget.getDescription()); + assertEquals(true, updatedWidget.getDeprecated()); + + // delete widget type + String widgetToDeleteId = createdWidgetTypes.get(0).getId().getId().toString(); + client.deleteWidgetType(widgetToDeleteId); + + // verify deletion + assertReturns404(() -> + client.getWidgetTypeById(widgetToDeleteId, false) + ); + + PageDataWidgetTypeInfo typesAfterDelete = client.getWidgetTypes(100, 0, + TEST_PREFIX + "Widget_" + timestamp, null, null, + true, false, null, null, null); + assertEquals(4, typesAfterDelete.getData().size()); + + // delete widgets bundle + client.deleteWidgetsBundle(savedBundle.getId().getId().toString()); + + assertReturns404(() -> + client.getWidgetsBundleById(savedBundle.getId().getId().toString(), false) + ); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java index fcbd60eb4d..62a266f26a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java @@ -18,6 +18,8 @@ package org.thingsboard.server.controller; import lombok.extern.slf4j.Slf4j; import org.mockito.ArgumentMatcher; import org.mockito.Mockito; +import org.springframework.boot.info.BuildProperties; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.EdgeUtils; @@ -59,6 +61,9 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest { @SpyBean protected AuditLogService auditLogService; + @MockBean + BuildProperties buildProperties; + protected final String msgErrorPermission = "You don't have permission to perform this operation!"; protected final String msgErrorShouldBeSpecified = "should be specified"; protected final String msgErrorNotFound = "Requested item wasn't found!"; 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 d6153b1825..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; @@ -123,6 +124,8 @@ import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationType; @@ -177,6 +180,7 @@ import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -434,6 +438,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { tenantProfileService.deleteTenantProfiles(TenantId.SYS_TENANT_ID); jdbcTemplate.execute("TRUNCATE TABLE notification"); + jdbcTemplate.execute("TRUNCATE TABLE audit_log"); log.debug("Executed web test teardown"); } @@ -726,6 +731,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return assetProfile; } + protected Device createDevice(String name) throws Exception { + return createDevice(name, "default", null, null); + } + protected Device createDevice(String name, String accessToken) throws Exception { return createDevice(name, "default", null, accessToken); } @@ -739,7 +748,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); deviceData.setConfiguration(new DefaultDeviceConfiguration()); device.setDeviceData(deviceData); - return doPost("/api/device?accessToken=" + accessToken, device, Device.class); + if (accessToken != null) { + return doPost("/api/device?accessToken=" + accessToken, device, Device.class); + } else { + return doPost("/api/device", device, Device.class); + } } protected Device assignDeviceToCustomer(DeviceId deviceId, CustomerId customerId) { @@ -1227,7 +1240,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { CalculatedFieldState calculatedFieldState = statesMap.get(cfId); boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastScheduledRefreshTs() < - System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); + System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady); return isReady; }); @@ -1277,7 +1290,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { TenantProfile oldTenantProfile = tenantProfileService.findDefaultTenantProfile(TenantId.SYS_TENANT_ID); TenantProfile tenantProfile = JacksonUtil.clone(oldTenantProfile); updater.accept(tenantProfile); - tbTenantProfileService.save(TenantId.SYS_TENANT_ID, tenantProfile, oldTenantProfile); + // user should be sysadmin as this operation allowed only for sysadmins. But for the simplification of the test - already existed variable provided. This affects only an audit log content + tbTenantProfileService.save(TenantId.SYS_TENANT_ID, tenantProfile, oldTenantProfile, tenantAdminUser); } protected OAuth2Client createOauth2Client(TenantId tenantId, String title) { @@ -1418,7 +1432,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected List findJobs(List types, List entities) throws Exception { return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) + - "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", + "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); } @@ -1432,21 +1446,50 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected void postTelemetry(EntityId entityId, String payload) throws Exception { doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + - "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + } + + protected void postTelemetry(EntityId entityId, TsKvEntry entry) throws Exception { + var values = JacksonUtil.newObjectNode(); + JacksonUtil.addKvEntry(values, entry); + + var payload = JacksonUtil.newObjectNode() + .put("ts", entry.getTs()) + .set("values", values); + + var url = "/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/timeseries/any"; + doPostAsync(url, payload, 30_000L).andExpect(status().isOk()); } protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception { doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + - "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + } + + protected void postAttributes(EntityId entityId, AttributeScope scope, KvEntry... attributes) throws Exception { + postAttributes(entityId, scope, Arrays.asList(attributes)); + } + + protected void postAttributes(EntityId entityId, AttributeScope scope, Collection attributes) throws Exception { + var url = "/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/attributes/" + scope; + var payload = JacksonUtil.newObjectNode(); + for (KvEntry entry : attributes) { + JacksonUtil.addKvEntry(payload, entry); + } + doPostAsync(url, payload, 30_000L).andExpect(status().isOk()); } protected CalculatedField saveCalculatedField(CalculatedField calculatedField) { 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); + (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); } protected PageData getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception { @@ -1459,11 +1502,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { List entities, List names) throws Exception { return doGetTypedWithPageLink("/api/calculatedFields?" + - (type != null ? "types=" + type + "&" : "") + - (entityType != null ? "entityType=" + entityType + "&" : "") + - (entities != null ? "entities=" + String.join(",", - entities.stream().map(UUID::toString).toList()) + "&" : "") + - (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""), + (type != null ? "types=" + type + "&" : "") + + (entityType != null ? "entityType=" + entityType + "&" : "") + + (entities != null ? "entities=" + String.join(",", + entities.stream().map(UUID::toString).toList()) + "&" : "") + + (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""), new TypeReference>() {}, new PageLink(10)).getData(); } 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/AuditLogControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java index a76da5f161..95582cf261 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java @@ -19,6 +19,7 @@ import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.core.type.TypeReference; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.awaitility.Awaitility; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -28,6 +29,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.mock.mockito.SpyBean; import org.thingsboard.server.common.data.Device; 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.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; @@ -60,6 +62,9 @@ import static org.mockito.Mockito.verify; @DaoSqlTest public class AuditLogControllerTest extends AbstractControllerTest { + // Small enough to force multiple pages, verifying pagination loop correctness + private static final int SMALL_PAGE_SIZE = 5; + private Tenant savedTenant; private User tenantAdmin; @@ -111,48 +116,47 @@ public class AuditLogControllerTest extends AbstractControllerTest { doPost("/api/device", device, Device.class); } - List loadedAuditLogs = new ArrayList<>(); - TimePageLink pageLink = new TimePageLink(5); - PageData pageData; - do { - pageData = doGetTypedWithTimePageLink("/api/audit/logs?", - new TypeReference>() { - }, pageLink); - loadedAuditLogs.addAll(pageData.getData()); - if (pageData.hasNext()) { - pageLink = pageLink.nextPageLink(); - } - } while (pageData.hasNext()); + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs?")).hasSize(11 + 1)); - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/customer/" + ModelConstants.NULL_UUID + "?")).hasSize(11 + 1)); - loadedAuditLogs = new ArrayList<>(); - pageLink = new TimePageLink(5); - do { - pageData = doGetTypedWithTimePageLink("/api/audit/logs/customer/" + ModelConstants.NULL_UUID + "?", - new TypeReference>() { - }, pageLink); - loadedAuditLogs.addAll(pageData.getData()); - if (pageData.hasNext()) { - pageLink = pageLink.nextPageLink(); - } - } while (pageData.hasNext()); + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/user/" + tenantAdmin.getId().getId().toString() + "?")).hasSize(11 + 1)); + } - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + @Test + public void testAuditLogsSysAdmin() throws Exception { + loginSysAdmin(); + List loadedAuditLogsBefore = getAuditLogs(100, "/api/audit/logs?"); + + for (int i = 0; i < 3; i++) { + TenantProfile tenantProfile = new TenantProfile(); + tenantProfile.setName("Profile " + UUID.randomUUID()); + doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + } - loadedAuditLogs = new ArrayList<>(); - pageLink = new TimePageLink(5); + int expectedSize = loadedAuditLogsBefore.size() + 3; + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + Assert.assertEquals("Have X audit log before this test + New tenant profiles in the test", + expectedSize, getAuditLogs(100, "/api/audit/logs?").size())); + } + + private List getAuditLogs(int pageSize, String urlTemplate) throws Exception { + List loadedAuditLogs = new ArrayList<>(); + TimePageLink pageLink = new TimePageLink(pageSize); + PageData pageData; do { - pageData = doGetTypedWithTimePageLink("/api/audit/logs/user/" + tenantAdmin.getId().getId().toString() + "?", - new TypeReference>() { + pageData = doGetTypedWithTimePageLink(urlTemplate, + new TypeReference<>() { }, pageLink); loadedAuditLogs.addAll(pageData.getData()); if (pageData.hasNext()) { pageLink = pageLink.nextPageLink(); } } while (pageData.hasNext()); - - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + return loadedAuditLogs; } @Test @@ -166,20 +170,31 @@ public class AuditLogControllerTest extends AbstractControllerTest { savedDevice = doPost("/api/device", savedDevice, Device.class); } - List loadedAuditLogs = new ArrayList<>(); - TimePageLink pageLink = new TimePageLink(5); - PageData pageData; - do { - pageData = doGetTypedWithTimePageLink("/api/audit/logs/entity/DEVICE/" + savedDevice.getId().getId() + "?", - new TypeReference>() { - }, pageLink); - loadedAuditLogs.addAll(pageData.getData()); - if (pageData.hasNext()) { - pageLink = pageLink.nextPageLink(); - } - } while (pageData.hasNext()); + Device finalSavedDevice = savedDevice; + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/entity/DEVICE/" + finalSavedDevice.getId().getId() + "?")).hasSize(11 + 1)); + } + + @Test + public void testAuditLogs_byTenantIdAndEntityId_Sysadmin() throws Exception { + loginSysAdmin(); + + //created + TenantProfile tenantProfile = new TenantProfile(); + tenantProfile.setName("Profile " + UUID.randomUUID()); + tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + //updated + tenantProfile.setName(tenantProfile.getName() + "(old)"); + tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + TenantProfile finalTenantProfile = tenantProfile; + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/entity/" + finalTenantProfile.getId().getEntityType() + "/" + finalTenantProfile.getId().getId() + "?")) + .as("Audit logs count by Tenant Profile entity").hasSize(2)); - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + //cleanup + doDelete("/api/tenantProfile/" + tenantProfile.getId().getId().toString()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index d9ec187e39..18e11faaf9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -18,6 +18,7 @@ package org.thingsboard.server.controller; 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; @@ -108,6 +109,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getSimpleCalculatedFieldConfig()); + assertThat(savedCalculatedField.getAdditionalInfo()).isEqualTo(calculatedField.getAdditionalInfo()); assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); savedCalculatedField.setName("Test CF"); @@ -115,6 +117,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getAdditionalInfo()).isEqualTo(savedCalculatedField.getAdditionalInfo()); assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) @@ -322,6 +325,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { calculatedField.setType(cfType); calculatedField.setName("Test Calculated Field for " + entityId); calculatedField.setConfigurationVersion(1); + calculatedField.setAdditionalInfo(JacksonUtil.newObjectNode()); if (customConfiguration != null) { calculatedField.setConfiguration(customConfiguration); } else switch (cfType) { diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 7332ee26c6..07b68210bb 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -1732,11 +1732,4 @@ public class DeviceControllerTest extends AbstractControllerTest { assertThat(fifthDevice.getName()).isEqualTo("My unique device_2"); } - private Device createDevice(String name) { - Device device = new Device(); - device.setName(name); - device.setType("default"); - return doPost("/api/device", device, Device.class); - } - } 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/EdqsEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java index f15ab09ab2..6c1a63194c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java @@ -23,6 +23,7 @@ import org.springframework.test.context.TestPropertySource; import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode; import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; +import org.awaitility.core.ThrowingRunnable; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; @@ -86,6 +87,11 @@ public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest { result -> result == expectedResult); } + @Override + protected void verifyAvailableKeysByQueryV2(ThrowingRunnable assertion) { + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(assertion); + } + @Test public void testEdqsState() throws Exception { loginSysAdmin(); 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 e1173ef9a7..835f00985b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -17,7 +17,11 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.BooleanNode; +import com.fasterxml.jackson.databind.node.DoubleNode; +import com.fasterxml.jackson.databind.node.IntNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.awaitility.core.ThrowingRunnable; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -43,12 +47,21 @@ import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +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.JsonDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataPageLink; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AliasEntityId; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; @@ -84,6 +97,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -1329,7 +1343,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { //assign dashboard doPost("/api/customer/" + savedCustomer.getId().getId().toString() - + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); // check entity data query by customer User customerUser = new User(); @@ -1454,6 +1468,10 @@ public class EntityQueryControllerTest extends AbstractControllerTest { return result; } + protected void verifyAvailableKeysByQueryV2(ThrowingRunnable assertion) throws Throwable { + assertion.run(); + } + private KeyFilter getEntityFieldEqualFilter(String keyName, String value) { return getEntityFieldKeyFilter(keyName, value, StringFilterPredicate.StringOperation.EQUAL); } @@ -1494,4 +1512,221 @@ public class EntityQueryControllerTest extends AbstractControllerTest { return nameFilter; } + // --- findAvailableEntityKeysV2 tests --- + + @Test + public void testFindAvailableKeysByQueryV2() throws Throwable { + // GIVEN — two devices matched by query; a third device should not be matched + var device1 = createDevice("Test device 1"); + var device2 = createDevice("Test device 2"); + var unmatchedDevice = createDevice("Unmatched device"); + + // unmatched device has unique keys that must NOT appear in the result + postTelemetry(unmatchedDevice.getId(), new BasicTsKvEntry(9000, new DoubleDataEntry("unmatchedTs", 999.0))); + postAttributes(unmatchedDevice.getId(), AttributeScope.SHARED_SCOPE, new StringDataEntry("unmatchedAttr", "nope")); + + // device1: timeseries1 (Double) with two data points, and timeseries2 older data point + postTelemetry(device1.getId(), new BasicTsKvEntry(1000, new DoubleDataEntry("timeseries1", 10.0))); + postTelemetry(device1.getId(), new BasicTsKvEntry(2000, new DoubleDataEntry("timeseries1", 20.5))); + postTelemetry(device1.getId(), new BasicTsKvEntry(1000, new LongDataEntry("timeseries2", 100L))); + + // device2: timeseries2 (Long) with a newer data point, and timeseries3 only on this device + postTelemetry(device2.getId(), new BasicTsKvEntry(3000, new LongDataEntry("timeseries2", 300L))); + postTelemetry(device2.getId(), new BasicTsKvEntry(5000, new DoubleDataEntry("timeseries3", 99.9))); + + // device1: SHARED_SCOPE attributes + postAttributes(device1.getId(), AttributeScope.SHARED_SCOPE, + new BooleanDataEntry("sharedAttribute1", true), new DoubleDataEntry("sharedAttribute2", 3.14)); + + // device2: CLIENT_SCOPE attributes (saved via service to bypass API restriction) + attributesService.save(tenantId, device2.getId(), AttributeScope.CLIENT_SCOPE, List.of( + new BaseAttributeKvEntry(new JsonDataEntry("clientAttribute1", "{\"key\":\"val\"}"), System.currentTimeMillis()), + new BaseAttributeKvEntry(new BooleanDataEntry("clientAttribute2", false), System.currentTimeMillis()) + )).get(); + + // device1 also has SERVER_SCOPE attributes (should be omitted by scope filter) + postAttributes(device1.getId(), AttributeScope.SERVER_SCOPE, + new StringDataEntry("serverAttribute1", "sv1"), new LongDataEntry("serverAttribute2", 42L)); + + // WHEN — query matches both devices; request timeseries + only SHARED and CLIENT attribute scopes + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter("Test device"); + EntityDataPageLink pageLink = new EntityDataPageLink(100, 0, null, null); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, List.of(), null, null); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(query, + true, true, List.of(AttributeScope.SHARED_SCOPE, AttributeScope.CLIENT_SCOPE), true); + + assertThat(result.entityTypes()).containsExactly(EntityType.DEVICE); + + // timeseries: keys collected from both devices, samples contain the freshest data points + assertThat(result.timeseries()).extracting(KeyInfo::key) + .containsExactly("timeseries1", "timeseries2", "timeseries3"); + assertThat(result.timeseries()).allSatisfy(ki -> assertThat(ki.sample()).isNotNull()); + assertKeySample(result.timeseries(), "timeseries1", new DoubleNode(20.5), 2000); // from device1 + assertKeySample(result.timeseries(), "timeseries2", new IntNode(300), 3000); // from device2 (newer) + assertKeySample(result.timeseries(), "timeseries3", new DoubleNode(99.9), 5000); // only on device2 + + // SERVER_SCOPE must be fully omitted from the response + assertThat(result.attributes()).containsOnlyKeys(AttributeScope.SHARED_SCOPE, AttributeScope.CLIENT_SCOPE); + + // SHARED_SCOPE: from device1 (alphabetical order) + assertThat(result.attributes().get(AttributeScope.SHARED_SCOPE)) + .extracting(KeyInfo::key).containsExactly("sharedAttribute1", "sharedAttribute2"); + assertKeySample(result.attributes().get(AttributeScope.SHARED_SCOPE), "sharedAttribute1", BooleanNode.TRUE); + assertKeySample(result.attributes().get(AttributeScope.SHARED_SCOPE), "sharedAttribute2", new DoubleNode(3.14)); + + // CLIENT_SCOPE: from device2 (alphabetical order) + assertThat(result.attributes().get(AttributeScope.CLIENT_SCOPE)) + .extracting(KeyInfo::key).containsExactly("clientAttribute1", "clientAttribute2"); + assertKeySample(result.attributes().get(AttributeScope.CLIENT_SCOPE), "clientAttribute1", JacksonUtil.toJsonNode("{\"key\":\"val\"}")); + assertKeySample(result.attributes().get(AttributeScope.CLIENT_SCOPE), "clientAttribute2", BooleanNode.FALSE); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_withoutSamples() throws Throwable { + // GIVEN + var device = createDevice("Test device"); + postTelemetry(device.getId(), new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", 10.0))); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("firmware", "v1.0")); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2( + buildDeviceQuery("Test device"), true, true, null, false); + + assertThat(result.timeseries()).allSatisfy(ki -> assertThat(ki.sample()).isNull()); + assertThat(result.attributes().get(AttributeScope.SERVER_SCOPE)) + .allSatisfy(ki -> assertThat(ki.sample()).isNull()); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_timeseriesOnly() throws Throwable { + // GIVEN + var device = createDevice("Test device"); + postTelemetry(device.getId(), new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", 10.0))); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("firmware", "v1.0")); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2( + buildDeviceQuery("Test device"), true, false, null, false); + + assertThat(result.timeseries()).extracting(KeyInfo::key).contains("temperature"); + assertThat(result.attributes()).isNull(); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_attributesOnly() throws Throwable { + // GIVEN + var device = createDevice("Test device"); + postTelemetry(device.getId(), new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", 10.0))); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("firmware", "v1.0")); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2( + buildDeviceQuery("Test device"), false, true, null, false); + + assertThat(result.timeseries()).isNull(); + assertThat(result.attributes().get(AttributeScope.SERVER_SCOPE)) + .extracting(KeyInfo::key).contains("firmware"); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_noMatchingEntities() throws Throwable { + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2( + buildDeviceQuery("NonExistentDevice_" + UUID.randomUUID()), true, true, null, true); + + assertThat(result.entityTypes()).isEmpty(); + assertThat(result.timeseries()).isEmpty(); + assertThat(result.attributes()).isEmpty(); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_assetUsesServerScopeOnly() throws Throwable { + // GIVEN + var asset = new Asset(); + asset.setName("Test asset"); + asset.setType("default"); + asset = doPost("/api/asset", asset, Asset.class); + postAttributes(asset.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("location", "warehouse")); + + // WHEN + var filter = new SingleEntityFilter(); + filter.setSingleEntity(AliasEntityId.fromEntityId(asset.getId())); + var query = new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(query, false, true, null, false); + + assertThat(result.entityTypes()).containsExactly(EntityType.ASSET); + assertThat(result.attributes()).containsOnlyKeys(AttributeScope.SERVER_SCOPE); + assertThat(result.attributes().get(AttributeScope.SERVER_SCOPE)) + .extracting(KeyInfo::key).containsExactly("location"); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_rejectsWhenNoKeyTypeRequested() throws Exception { + // WHEN / THEN + EntityDataQuery query = buildDeviceQuery("NonExistent"); + + doPostAsync("/api/v2/entitiesQuery/find/keys?includeTimeseries=false&includeAttributes=false", + query, 30_000L).andExpect(status().isBadRequest()); + } + + protected AvailableEntityKeysV2 findAvailableEntityKeysByQueryV2(EntityDataQuery query, + boolean includeTimeseries, boolean includeAttributes, + List scopes, boolean includeSamples) throws Exception { + StringBuilder url = new StringBuilder("/api/v2/entitiesQuery/find/keys?") + .append("includeTimeseries=").append(includeTimeseries) + .append("&includeAttributes=").append(includeAttributes) + .append("&includeSamples=").append(includeSamples); + if (scopes != null) { + for (AttributeScope scope : scopes) { + url.append("&scopes=").append(scope); + } + } + return doPostAsyncWithTypedResponse(url.toString(), query, + new TypeReference<>() {}, status().isOk()); + } + + private static void assertKeySample(List keys, String expectedKey, JsonNode expectedValue, long expectedTs) { + KeyInfo keyInfo = findKeyInfo(keys, expectedKey); + assertThat(keyInfo.sample()).isNotNull(); + assertThat(keyInfo.sample().value()).isEqualTo(expectedValue); + assertThat(keyInfo.sample().ts()).isEqualTo(expectedTs); + } + + private static void assertKeySample(List keys, String expectedKey, JsonNode expectedValue) { + KeyInfo keyInfo = findKeyInfo(keys, expectedKey); + assertThat(keyInfo.sample()).isNotNull(); + assertThat(keyInfo.sample().value()).isEqualTo(expectedValue); + assertThat(keyInfo.sample().ts()).isGreaterThan(0); + } + + private static KeyInfo findKeyInfo(List keys, String key) { + return keys.stream() + .filter(ki -> ki.key().equals(key)).findFirst().orElseThrow(); + } + + private static EntityDataQuery buildDeviceQuery(String deviceName) { + var filter = new DeviceTypeFilter(); + filter.setDeviceTypes(Collections.singletonList("default")); + filter.setDeviceNameFilter(deviceName); + return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java index b0c76a16a8..b4ce7179d2 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java @@ -633,13 +633,6 @@ public class EntityRelationControllerTest extends AbstractControllerTest { deleteDifferentTenant(); } - private Device createDevice(String name) { - var device = new Device(); - device.setName(name); - device.setType("default"); - return doPost("/api/device", device, Device.class); - } - private ResultActions getRelation(EntityRelation relation) throws Exception { return doGet("/api/relation?" + "fromId=" + relation.getFrom().getId() + diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java index d53e69d721..bcffd9099e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java @@ -448,4 +448,21 @@ public class RuleChainControllerTest extends AbstractControllerTest { return doPost("/api/ruleChain", ruleChain, RuleChain.class); } + @Test + public void testScriptForbiddenForCustomer() throws Exception { + loginCustomerUser(); + + doPost("/api/ruleChain/testScript", (Object) """ + { + "script": "return msg;", + "scriptType": "update", + "argNames": ["msg", "metadata", "msgType"], + "msg": "{}", + "metadata": {}, + "msgType": "POST_TELEMETRY_REQUEST" + } + """) + .andExpect(status().isForbidden()); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java index 42a27eb6d8..a2e622c8b5 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; +import org.awaitility.Awaitility; import org.junit.Assert; import org.junit.Test; import org.mockito.ArgumentMatcher; @@ -25,9 +26,12 @@ import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.audit.AuditLog; +import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.queue.ProcessingStrategy; import org.thingsboard.server.common.data.queue.ProcessingStrategyType; @@ -47,6 +51,7 @@ import java.util.Collections; 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; @@ -81,12 +86,17 @@ public class TenantProfileControllerTest extends AbstractControllerTest { testBroadcastEntityStateChangeEventTimeManyTimeTenantProfile(savedTenantProfile, ComponentLifecycleEvent.CREATED, 1); + awaitAuditLog("Wait for async audit log to be persisted (ADDED expected)", savedTenantProfile.getId(), ActionType.ADDED); + savedTenantProfile.setName("New tenant profile"); doPost("/api/tenantProfile", savedTenantProfile, TenantProfile.class); TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + savedTenantProfile.getId().getId().toString(), TenantProfile.class); Assert.assertEquals(foundTenantProfile.getName(), savedTenantProfile.getName()); testBroadcastEntityStateChangeEventTimeManyTimeTenantProfile(savedTenantProfile, ComponentLifecycleEvent.UPDATED, 1); + + awaitAuditLog("Wait for async audit log to be persisted (UPDATED expected)", savedTenantProfile.getId(), ActionType.UPDATED); + } @Test @@ -180,6 +190,8 @@ public class TenantProfileControllerTest extends AbstractControllerTest { Assert.assertNotNull(foundDefaultTenantProfile); Assert.assertEquals(savedTenantProfile.getName(), foundDefaultTenantProfile.getName()); Assert.assertEquals(savedTenantProfile.getId(), foundDefaultTenantProfile.getId()); + + awaitAuditLog("Wait for async audit log to be persisted (UPDATED expected for NEW DEFAULT Tenant Profile)", savedTenantProfile.getId(), ActionType.UPDATED); } @Test @@ -245,6 +257,8 @@ public class TenantProfileControllerTest extends AbstractControllerTest { doDelete("/api/tenantProfile/" + savedTenantProfile.getId().getId().toString()) .andExpect(status().isOk()); + awaitAuditLog("Wait for async audit log to be persisted for deleted tenant profile", savedTenantProfile.getId(), ActionType.DELETED); + testBroadcastEntityStateChangeEventTimeManyTimeTenantProfile(savedTenantProfile, ComponentLifecycleEvent.DELETED, 1); doGet("/api/tenantProfile/" + savedTenantProfile.getId().getId().toString()) @@ -393,6 +407,17 @@ public class TenantProfileControllerTest extends AbstractControllerTest { testBroadcastEntityStateChangeEventNeverTenantProfile(); } + private void awaitAuditLog(String awaitMessage, TenantProfileId tenantProfileId, ActionType expectedAction) { + Awaitility.await(awaitMessage) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> doGetTypedWithTimePageLink( + "/api/audit/logs?", + new TypeReference>() {}, + new TimePageLink(100)).getData().stream() + .anyMatch(log -> log.getEntityId().equals(tenantProfileId) && log.getActionType() == expectedAction) + ); + } + private TenantProfile createTenantProfile(String name) { TenantProfile tenantProfile = new TenantProfile(); tenantProfile.setName(name); @@ -429,7 +454,6 @@ public class TenantProfileControllerTest extends AbstractControllerTest { tenantProfile.setProfileData(profileData); } - private void testBroadcastEntityStateChangeEventTimeManyTimeTenantProfile(TenantProfile tenantProfile, ComponentLifecycleEvent event, int cntTime) { ArgumentMatcher matcherTenantProfile = cntTime == 1 ? argument -> argument.equals(tenantProfile) : argument -> argument.getClass().equals(TenantProfile.class); 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 053cb6808f..4dc637725f 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 @@ -25,16 +25,28 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; 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.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.ws.WebSocketSessionRef; import java.io.IOException; +import java.lang.reflect.Method; import java.util.Collection; import java.util.Deque; import java.util.List; import java.util.Random; +import java.util.UUID; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; @@ -184,4 +196,70 @@ 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; + } + } diff --git a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java index 260476fe4d..d75c7b8f59 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java @@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.OtaPackageInfo; import org.thingsboard.server.common.data.SaveOtaPackageInfoRequest; import org.thingsboard.server.common.data.StringUtils; @@ -605,7 +606,7 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { DeviceCredentials deviceCredentialsMsg = JacksonUtil.fromString(deviceCredentialsUpdateMsg.getEntity(), DeviceCredentials.class, true); Assert.assertNotNull(deviceCredentialsMsg); Assert.assertEquals(savedDevice.getId(), deviceCredentialsMsg.getDeviceId()); - Assert.assertEquals(deviceCredentials, deviceCredentialsMsg); + compareHasVersionEntities(deviceCredentials, deviceCredentialsMsg); return savedDevice; } @@ -775,4 +776,10 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { }); } + protected void compareHasVersionEntities(HasVersion entity1, HasVersion entity2) { + entity1.setVersion(null); + entity2.setVersion(null); + Assert.assertEquals(entity1, entity2); + } + } diff --git a/application/src/test/java/org/thingsboard/server/edge/ApiKeyEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/ApiKeyEdgeTest.java new file mode 100644 index 0000000000..bd46cb5251 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/ApiKeyEdgeTest.java @@ -0,0 +1,238 @@ +/** + * 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.edge; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.protobuf.AbstractMessage; +import com.google.protobuf.InvalidProtocolBufferException; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.pat.ApiKeyService; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.gen.edge.v1.UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE; + +@DaoSqlTest +public class ApiKeyEdgeTest extends AbstractEdgeTest { + + @Autowired + private ApiKeyService apiKeyService; + + private static final String DEFAULT_API_KEY_DESCRIPTION = "Edge Test ApiKey"; + private static final String UPDATED_API_KEY_DESCRIPTION = "Updated Edge Test ApiKey"; + + @Test + public void testApiKey_create_update_delete_fromCloud() throws Exception { + // create ApiKey + ApiKeyInfo apiKeyInfo = createSimpleApiKeyInfo(DEFAULT_API_KEY_DESCRIPTION); + + edgeImitator.expectMessageAmount(1); + ApiKey savedApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof ApiKeyUpdateMsg); + ApiKeyUpdateMsg apiKeyUpdateMsg = (ApiKeyUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); + Assert.assertEquals(savedApiKey.getUuidId().getMostSignificantBits(), apiKeyUpdateMsg.getIdMSB()); + Assert.assertEquals(savedApiKey.getUuidId().getLeastSignificantBits(), apiKeyUpdateMsg.getIdLSB()); + ApiKey apiKeyFromMsg = JacksonUtil.fromString(apiKeyUpdateMsg.getEntity(), ApiKey.class, true); + Assert.assertNotNull(apiKeyFromMsg); + + Assert.assertEquals(DEFAULT_API_KEY_DESCRIPTION, apiKeyFromMsg.getDescription()); + Assert.assertEquals(savedApiKey.getTenantId(), apiKeyFromMsg.getTenantId()); + + // update ApiKey + edgeImitator.expectMessageAmount(1); + savedApiKey.setDescription(UPDATED_API_KEY_DESCRIPTION); + savedApiKey = doPost("/api/apiKey", new ApiKeyInfo(savedApiKey), ApiKey.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof ApiKeyUpdateMsg); + apiKeyUpdateMsg = (ApiKeyUpdateMsg) latestMessage; + apiKeyFromMsg = JacksonUtil.fromString(apiKeyUpdateMsg.getEntity(), ApiKey.class, true); + Assert.assertNotNull(apiKeyFromMsg); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); + Assert.assertEquals(UPDATED_API_KEY_DESCRIPTION, apiKeyFromMsg.getDescription()); + + // delete ApiKey + edgeImitator.expectMessageAmount(1); + doDelete("/api/apiKey/" + savedApiKey.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof ApiKeyUpdateMsg); + apiKeyUpdateMsg = (ApiKeyUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); + Assert.assertEquals(savedApiKey.getUuidId().getMostSignificantBits(), apiKeyUpdateMsg.getIdMSB()); + Assert.assertEquals(savedApiKey.getUuidId().getLeastSignificantBits(), apiKeyUpdateMsg.getIdLSB()); + } + + @Test + public void testApiKey_create_update_delete_toCloud() throws Exception { + // create + ApiKey apiKey = createSimpleApiKey(DEFAULT_API_KEY_DESCRIPTION); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, apiKey, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkApiKeyOnCloud(uplinkMsg, uuid, apiKey.getDescription()); + + // update + apiKey.setDescription(UPDATED_API_KEY_DESCRIPTION); + UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, apiKey, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + + checkApiKeyOnCloud(updatedUplinkMsg, uuid, apiKey.getDescription()); + + // delete + UplinkMsg deleteUplinkMsg = getDeleteUplinkMsg(uuid); + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(deleteUplinkMsg); + Assert.assertTrue(edgeImitator.waitForResponses()); + + ApiKeyId apiKeyId = new ApiKeyId(uuid); + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + Assert.assertNull(apiKeyService.findApiKeyById(tenantId, apiKeyId)) + ); + } + + @Test + public void testApiKey_pushedDuringUserSync() throws Exception { + // create tenant admin user - expect 3 messages: 1 UserUpdateMsg + 2 UserCredentialsUpdateMsg + User user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setTenantId(tenantId); + user.setEmail("apiKeyTestUser@thingsboard.org"); + user.setFirstName("ApiKey"); + user.setLastName("TestUser"); + + edgeImitator.expectMessageAmount(3); + User savedUser = createUser(user, "tenant"); + Assert.assertTrue(edgeImitator.waitForMessages()); + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); + Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); + + // create API key for this user - expect 1 ApiKeyUpdateMsg + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setTenantId(tenantId); + apiKeyInfo.setUserId(savedUser.getId()); + apiKeyInfo.setDescription("Test API Key for user sync"); + apiKeyInfo.setEnabled(true); + + edgeImitator.expectMessageAmount(1); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(ApiKeyUpdateMsg.class).size()); + + // update user - expect 3 messages: UserUpdateMsg + UserCredentialsUpdateMsg + ApiKeyUpdateMsg + savedUser.setLastName("UpdatedLastName"); + edgeImitator.expectMessageAmount(3); + doPost("/api/user", savedUser, User.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(ApiKeyUpdateMsg.class).size()); + + Optional apiKeyUpdateMsgOpt = edgeImitator.findMessageByType(ApiKeyUpdateMsg.class); + Assert.assertTrue(apiKeyUpdateMsgOpt.isPresent()); + ApiKeyUpdateMsg apiKeyUpdateMsg = apiKeyUpdateMsgOpt.get(); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); + } + + private ApiKeyInfo createSimpleApiKeyInfo(String description) { + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setTenantId(tenantId); + apiKeyInfo.setUserId(tenantAdminUserId); + apiKeyInfo.setDescription(description); + apiKeyInfo.setEnabled(true); + return apiKeyInfo; + } + + private ApiKey createSimpleApiKey(String description) { + ApiKey apiKey = new ApiKey(); + apiKey.setTenantId(tenantId); + apiKey.setUserId(tenantAdminUserId); + apiKey.setDescription(description); + apiKey.setEnabled(true); + apiKey.setValue("test-api-key-value-" + UUID.randomUUID()); + return apiKey; + } + + private UplinkMsg getDeleteUplinkMsg(UUID uuid) throws InvalidProtocolBufferException { + UplinkMsg.Builder upLinkMsgBuilder = UplinkMsg.newBuilder(); + ApiKeyUpdateMsg.Builder apiKeyDeleteMsgBuilder = ApiKeyUpdateMsg.newBuilder(); + apiKeyDeleteMsgBuilder.setMsgType(ENTITY_DELETED_RPC_MESSAGE); + apiKeyDeleteMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + apiKeyDeleteMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + testAutoGeneratedCodeByProtobuf(apiKeyDeleteMsgBuilder); + + upLinkMsgBuilder.addApiKeyUpdateMsg(apiKeyDeleteMsgBuilder.build()); + testAutoGeneratedCodeByProtobuf(upLinkMsgBuilder); + + return upLinkMsgBuilder.build(); + } + + private UplinkMsg getUplinkMsg(UUID uuid, ApiKey apiKey, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException { + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + ApiKeyUpdateMsg.Builder apiKeyUpdateMsgBuilder = ApiKeyUpdateMsg.newBuilder(); + apiKeyUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + apiKeyUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + apiKeyUpdateMsgBuilder.setEntity(JacksonUtil.toString(apiKey)); + apiKeyUpdateMsgBuilder.setMsgType(updateMsgType); + testAutoGeneratedCodeByProtobuf(apiKeyUpdateMsgBuilder); + uplinkMsgBuilder.addApiKeyUpdateMsg(apiKeyUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + return uplinkMsgBuilder.build(); + } + + private void checkApiKeyOnCloud(UplinkMsg uplinkMsg, UUID uuid, String description) throws Exception { + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + ApiKey apiKey = apiKeyService.findApiKeyById(tenantId, new ApiKeyId(uuid)); + Assert.assertNotNull(apiKey); + Assert.assertEquals(description, apiKey.getDescription()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java index 96c6dd42b7..843011c24f 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java @@ -58,7 +58,7 @@ public class AssetEdgeTest extends AbstractEdgeTest { Asset assetMsg = JacksonUtil.fromString(assetUpdateMsg.getEntity(), Asset.class, true); Assert.assertNotNull(assetMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, assetUpdateMsg.getMsgType()); - Assert.assertEquals(savedAsset, assetMsg); + compareHasVersionEntities(savedAsset, assetMsg); Optional assetProfileUpdateMsgOpt = edgeImitator.findMessageByType(AssetProfileUpdateMsg.class); Assert.assertTrue(assetProfileUpdateMsgOpt.isPresent()); AssetProfileUpdateMsg assetProfileUpdateMsg = assetProfileUpdateMsgOpt.get(); @@ -109,7 +109,7 @@ public class AssetEdgeTest extends AbstractEdgeTest { assetMsg = JacksonUtil.fromString(assetUpdateMsg.getEntity(), Asset.class, true); Assert.assertNotNull(assetMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, assetUpdateMsg.getMsgType()); - Assert.assertEquals(savedAsset, assetMsg); + compareHasVersionEntities(savedAsset, assetMsg); assetProfileUpdateMsgOpt = edgeImitator.findMessageByType(AssetProfileUpdateMsg.class); Assert.assertTrue(assetProfileUpdateMsgOpt.isPresent()); assetProfileUpdateMsg = assetProfileUpdateMsgOpt.get(); diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java index 68548558f7..210b2799e0 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java @@ -53,7 +53,7 @@ public class AssetProfileEdgeTest extends AbstractEdgeTest { AssetProfileUpdateMsg assetProfileUpdateMsg = (AssetProfileUpdateMsg) latestMessage; AssetProfile assetProfileMsg = JacksonUtil.fromString(assetProfileUpdateMsg.getEntity(), AssetProfile.class, true); Assert.assertNotNull(assetProfileMsg); - Assert.assertEquals(assetProfile, assetProfileMsg); + compareHasVersionEntities(assetProfile, assetProfileMsg); Assert.assertEquals("Building", assetProfileMsg.getName()); Assert.assertEquals(buildingsRuleChainId, assetProfileMsg.getDefaultEdgeRuleChainId()); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, assetProfileUpdateMsg.getMsgType()); diff --git a/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java index 6199657936..52a30f0e85 100644 --- a/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java @@ -73,6 +73,7 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { Assert.assertEquals(DEFAULT_CF_NAME, calculatedFieldFromMsg.getName()); Assert.assertEquals(savedDevice.getId(), calculatedFieldFromMsg.getEntityId()); Assert.assertEquals(config, calculatedFieldFromMsg.getConfiguration()); + Assert.assertEquals(calculatedField.getAdditionalInfo(), calculatedFieldFromMsg.getAdditionalInfo()); edgeImitator.expectMessageAmount(1); savedCalculatedField.setName(UPDATED_CF_NAME); @@ -147,7 +148,7 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = (CalculatedFieldUpdateMsg) latestMessage; CalculatedField calculatedFieldFromEdge = JacksonUtil.fromString(calculatedFieldUpdateMsg.getEntity(), CalculatedField.class, true); Assert.assertNotNull(calculatedFieldFromEdge); - Assert.assertEquals(savedCalculatedField, calculatedFieldFromEdge); + compareHasVersionEntities(savedCalculatedField, calculatedFieldFromEdge); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, calculatedFieldUpdateMsg.getMsgType()); } @@ -229,6 +230,7 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { config.setOutput(output); calculatedField.setConfiguration(config); + calculatedField.setAdditionalInfo(JacksonUtil.newObjectNode()); return calculatedField; } @@ -260,6 +262,7 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { CalculatedField calculatedField = doGet("/api/calculatedField/" + uuid, CalculatedField.class); Assert.assertNotNull(calculatedField); Assert.assertEquals(resourceTitle, calculatedField.getName()); + Assert.assertEquals(JacksonUtil.newObjectNode(), calculatedField.getAdditionalInfo()); } } diff --git a/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java index f3d12675a3..578175dc46 100644 --- a/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java @@ -60,7 +60,7 @@ public class CustomerEdgeTest extends AbstractEdgeTest { CustomerUpdateMsg customerUpdateMsg = customerUpdateOpt.get(); Customer customerMsg = JacksonUtil.fromString(customerUpdateMsg.getEntity(), Customer.class, true); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, customerUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomer, customerMsg); + compareHasVersionEntities(savedCustomer, customerMsg); testAutoGeneratedCodeByProtobuf(customerUpdateMsg); // update customer @@ -73,7 +73,7 @@ public class CustomerEdgeTest extends AbstractEdgeTest { customerUpdateMsg = (CustomerUpdateMsg) latestMessage; customerMsg = JacksonUtil.fromString(customerUpdateMsg.getEntity(), Customer.class, true); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, customerUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomer, customerMsg); + compareHasVersionEntities(savedCustomer, customerMsg); // delete customer edgeImitator.expectMessageAmount(2); diff --git a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java index 9c70db267e..788dff02c0 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java @@ -201,7 +201,7 @@ public class DashboardEdgeTest extends AbstractEdgeTest { CustomerUpdateMsg customerUpdateMsg = customerUpdateOpt.get(); Customer customerMsg = JacksonUtil.fromString(customerUpdateMsg.getEntity(), Customer.class, true); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, customerUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomer, customerMsg); + compareHasVersionEntities(savedCustomer, customerMsg); Dashboard dashboard = buildDashboardForUplinkMsg(savedCustomer); diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java index 9349f79c02..369cda37aa 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java @@ -141,7 +141,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Device deviceFromMsg = JacksonUtil.fromString(deviceUpdateMsg.getEntity(), Device.class, true); Assert.assertNotNull(deviceFromMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceUpdateMsg.getMsgType()); - Assert.assertEquals(savedDevice, deviceFromMsg); + compareHasVersionEntities(savedDevice, deviceFromMsg); Assert.assertEquals(savedDevice.getId(), deviceFromMsg.getId()); Assert.assertEquals(savedDevice.getName(), deviceFromMsg.getName()); Assert.assertEquals(savedDevice.getType(), deviceFromMsg.getType()); @@ -222,7 +222,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Assert.assertTrue(latestMessage instanceof DeviceCredentialsUpdateMsg); DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg = (DeviceCredentialsUpdateMsg) latestMessage; DeviceCredentials deviceCredentialsMsg = JacksonUtil.fromString(deviceCredentialsUpdateMsg.getEntity(), DeviceCredentials.class, true); - Assert.assertEquals(deviceCredentials, deviceCredentialsMsg); + compareHasVersionEntities(deviceCredentials, deviceCredentialsMsg); // update device credentials - X509_CERTIFICATE edgeImitator.expectMessageAmount(1); @@ -272,7 +272,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Device deviceMsg = JacksonUtil.fromString(deviceUpdateMsg.getEntity(), Device.class, true); Assert.assertNotNull(deviceMsg); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, deviceUpdateMsg.getMsgType()); - Assert.assertEquals(savedDevice, deviceMsg); + compareHasVersionEntities(savedDevice, deviceMsg); Assert.assertEquals(firmwareOtaPackageInfo.getId(), deviceMsg.getFirmwareId()); Assert.assertEquals(softwareOtaPackageInfo.getId(), deviceMsg.getSoftwareId()); deviceData = deviceMsg.getDeviceData(); @@ -387,7 +387,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { DeviceCredentials deviceCredentialsMsg = JacksonUtil.fromString(deviceCredentialsUpdateMsg.getEntity(), DeviceCredentials.class, true); Assert.assertNotNull(deviceCredentialsMsg); Assert.assertEquals(device.getId(), deviceCredentialsMsg.getDeviceId()); - Assert.assertEquals(deviceCredentials, deviceCredentialsMsg); + compareHasVersionEntities(deviceCredentials, deviceCredentialsMsg); } @Test diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java index f44b4e295e..3ac7b3d648 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java @@ -83,7 +83,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); // update device profile @@ -108,7 +108,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); // delete profile edgeImitator.expectMessageAmount(1); @@ -146,7 +146,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); // delete profile when edge is offline @@ -186,7 +186,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); Assert.assertEquals(DeviceTransportType.SNMP, deviceProfileMsg.getTransportType()); @@ -224,7 +224,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); Assert.assertEquals(DeviceTransportType.LWM2M, deviceProfileMsg.getTransportType()); @@ -273,7 +273,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); Assert.assertEquals(DeviceTransportType.COAP, deviceProfileMsg.getTransportType()); diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java index 334680f7a7..e9dbf6d443 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.edge; +import com.fasterxml.jackson.databind.JsonNode; import org.junit.Assert; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; @@ -26,10 +28,14 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.CustomerUpdateMsg; import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; +import java.util.List; import java.util.Optional; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + @DaoSqlTest public class EdgeTest extends AbstractEdgeTest { @@ -57,7 +63,7 @@ public class EdgeTest extends AbstractEdgeTest { CustomerUpdateMsg customerUpdateMsg = customerUpdateOpt.get(); Customer customerMsg = JacksonUtil.fromString(customerUpdateMsg.getEntity(), Customer.class, true); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, customerUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomer, customerMsg); + compareHasVersionEntities(savedCustomer, customerMsg); // unassign edge from customer edgeImitator.expectMessageAmount(2); @@ -76,4 +82,23 @@ public class EdgeTest extends AbstractEdgeTest { Assert.assertEquals(savedCustomer.getUuidId().getMostSignificantBits(), customerUpdateMsg.getIdMSB()); Assert.assertEquals(savedCustomer.getUuidId().getLeastSignificantBits(), customerUpdateMsg.getIdLSB()); } + + @Test + public void testSyncEdge_attributeUpdated() throws Exception { + getWsClient().subscribeForAttributes(edge.getId(), TbAttributeSubscriptionScope.SERVER_SCOPE.name(), List.of(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY)); + + doPost("/api/edge/sync/" + edge.getId()); + + // wait for sync to start + waitForEdgeSyncInProgressEqualsValue(true); + + // wait for sync to end + waitForEdgeSyncInProgressEqualsValue(false); + } + + private void waitForEdgeSyncInProgressEqualsValue(Boolean value) { + getWsClient().registerWaitForUpdate(); + JsonNode update = JacksonUtil.toJsonNode(getWsClient().waitForUpdate()); + assertThat(update.get("data").get(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY).get(0).get(1).asBoolean()).isEqualTo(value); + } } diff --git a/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java index ce7b915457..640545c5d4 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java @@ -68,7 +68,7 @@ public class EntityViewEdgeTest extends AbstractEdgeTest { EntityViewUpdateMsg entityViewUpdateMsg = (EntityViewUpdateMsg) latestMessage; EntityView entityView = JacksonUtil.fromString(entityViewUpdateMsg.getEntity(), EntityView.class, true); Assert.assertNotNull(entityView); - Assert.assertEquals(savedEntityView, entityView); + compareHasVersionEntities(savedEntityView, entityView); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, entityViewUpdateMsg.getMsgType()); // request entity view(s) for device @@ -265,7 +265,7 @@ public class EntityViewEdgeTest extends AbstractEdgeTest { EntityViewUpdateMsg entityViewUpdateMsg = (EntityViewUpdateMsg) latestMessage; EntityView entityViewMsg = JacksonUtil.fromString(entityViewUpdateMsg.getEntity(), EntityView.class, true); Assert.assertNotNull(entityViewMsg); - Assert.assertEquals(entityView, entityViewMsg); + compareHasVersionEntities(entityView, entityViewMsg); Assert.assertEquals(device.getId(), entityViewMsg.getEntityId()); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, entityViewUpdateMsg.getMsgType()); testAutoGeneratedCodeByProtobuf(entityViewUpdateMsg); diff --git a/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java index d5458ce793..193dc57c36 100644 --- a/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java @@ -266,7 +266,6 @@ public class NotificationEdgeTest extends AbstractEdgeTest { notificationRule.setTriggerConfig(triggerConfig); EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ALARM); Map> escalationTable = new HashMap<>(); escalationTable.put(Integer.valueOf("1"), new ArrayList<>()); recipientsConfig.setEscalationTable(escalationTable); diff --git a/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java index 054f67585e..cc23440deb 100644 --- a/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java @@ -57,7 +57,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { RelationUpdateMsg relationUpdateMsg = (RelationUpdateMsg) latestMessage; EntityRelation entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(relation, entityRelation); + compareHasVersionEntities(relation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); // delete relation @@ -76,7 +76,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { relationUpdateMsg = (RelationUpdateMsg) latestMessage; entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(deletedRelation, entityRelation); + compareHasVersionEntities(deletedRelation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); } @@ -155,7 +155,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { RelationUpdateMsg relationUpdateMsg = (RelationUpdateMsg) latestMessage; EntityRelation entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(deviceToAssetRelation, entityRelation); + compareHasVersionEntities(deviceToAssetRelation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); } @@ -177,7 +177,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { RelationUpdateMsg relationUpdateMsg = (RelationUpdateMsg) latestMessage; EntityRelation entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(relation, entityRelation); + compareHasVersionEntities(relation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); // delete relation @@ -196,7 +196,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { relationUpdateMsg = (RelationUpdateMsg) latestMessage; entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(deletedRelation, entityRelation); + compareHasVersionEntities(deletedRelation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); } diff --git a/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java index 283e8bb66b..812357ac7d 100644 --- a/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java @@ -50,7 +50,7 @@ public class TenantEdgeTest extends AbstractEdgeTest { TenantProfileUpdateMsg tenantProfileUpdateMsg = tenantProfileUpdateMsgOpt.get(); Tenant tenantMsg = JacksonUtil.fromString(tenantUpdateMsg.getEntity(), Tenant.class, true); Assert.assertNotNull(tenantMsg); - Assert.assertEquals(savedTenant, tenantMsg); + compareHasVersionEntities(savedTenant, tenantMsg); TenantProfile tenantProfileMsg = JacksonUtil.fromString(tenantProfileUpdateMsg.getEntity(), TenantProfile.class, true); Assert.assertNotNull(tenantProfileMsg); Assert.assertEquals(tenantMsg.getTenantProfileId(), tenantProfileMsg.getId()); @@ -75,7 +75,7 @@ public class TenantEdgeTest extends AbstractEdgeTest { Assert.assertNotNull(tenantProfileMsg); // tenant update Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, tenantUpdateMsg.getMsgType()); - Assert.assertEquals(savedTenant, tenantMsg); + compareHasVersionEntities(savedTenant, tenantMsg); Assert.assertEquals(savedTenant.getTenantProfileId(), tenantProfileMsg.getId()); } 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/edge/WidgetEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java index d6afa81262..ddaa867aee 100644 --- a/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java @@ -68,7 +68,7 @@ public class WidgetEdgeTest extends AbstractEdgeTest { WidgetType widgetsType = JacksonUtil.fromString(widgetTypeUpdateMsg.getEntity(), WidgetType.class, true); Assert.assertNotNull(widgetsType); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, widgetTypeUpdateMsg.getMsgType()); - Assert.assertEquals(savedWidgetType, widgetsType); + compareHasVersionEntities(savedWidgetType, widgetsType); // update widget bundle edgeImitator.expectMessageAmount(1); diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index 8cf2b309f6..8208dc4fc9 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -30,6 +30,7 @@ import org.thingsboard.edge.rpc.EdgeRpcClient; import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -375,6 +376,11 @@ public class EdgeImitator { result.add(saveDownlinkMsg(aiModelUpdateMsg)); } } + if (downlinkMsg.getApiKeyUpdateMsgCount() > 0) { + for (ApiKeyUpdateMsg apiKeyUpdateMsg : downlinkMsg.getApiKeyUpdateMsgList()) { + result.add(saveDownlinkMsg(apiKeyUpdateMsg)); + } + } if (downlinkMsg.hasEdgeConfiguration()) { result.add(saveDownlinkMsg(downlinkMsg.getEdgeConfiguration())); } 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/apiusage/ApiUsageTest.java b/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java index da73600671..4cd41c314e 100644 --- a/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java +++ b/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java @@ -19,6 +19,8 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; @@ -30,6 +32,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.controller.TbUrlConstants; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -46,6 +49,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @TestPropertySource(properties = { "usage.stats.report.enabled=true", "usage.stats.report.interval=2", + "usage.stats.report.urgent_interval=1", "usage.stats.gauge_report_interval=1", }) public class ApiUsageTest extends AbstractControllerTest { @@ -54,9 +58,12 @@ public class ApiUsageTest extends AbstractControllerTest { private User tenantAdmin; private static final int MAX_DP_ENABLE_VALUE = 12; + private static final int MAX_SMS_ENABLE_VALUE = 10; private static final double WARN_THRESHOLD_VALUE = 0.5; @Autowired private ApiUsageStateService apiUsageStateService; + @Autowired + private TbApiUsageReportClient apiUsageReportClient; @Before public void beforeTest() throws Exception { @@ -82,7 +89,7 @@ public class ApiUsageTest extends AbstractControllerTest { } @Test - public void testTelemetryApiCall() throws Exception { + public void testDbStorageApiUsage() throws Exception { Device device = createDevice(); assertNotNull(device); String telemetryPayload = "{\"temperature\":25, \"humidity\":60}"; @@ -94,7 +101,8 @@ public class ApiUsageTest extends AbstractControllerTest { doPostAsync(url, telemetryPayload, String.class, status().isOk()); } - await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> assertEquals(ApiUsageStateValue.WARNING, apiUsageStateService.findTenantApiUsageState(tenantId).getDbStorageState())); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertEquals(ApiUsageStateValue.WARNING, getUsageState().getDbStorageState())); long VALUE_DISABLE = (long) (MAX_DP_ENABLE_VALUE - (MAX_DP_ENABLE_VALUE * WARN_THRESHOLD_VALUE)) / 2; @@ -104,10 +112,35 @@ public class ApiUsageTest extends AbstractControllerTest { await().atMost(TIMEOUT, TimeUnit.SECONDS) .untilAsserted(() -> { - assertEquals(ApiUsageStateValue.DISABLED, apiUsageStateService.findTenantApiUsageState(tenantId).getDbStorageState()); + assertEquals(ApiUsageStateValue.DISABLED, getUsageState().getDbStorageState()); }); } + @Test + public void testSmsApiUsage() { + long smsWarnThreshold = (long) (MAX_SMS_ENABLE_VALUE * WARN_THRESHOLD_VALUE); + + for (int i = 0; i < smsWarnThreshold; i++) { + apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.SMS_EXEC_COUNT); + } + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertEquals(ApiUsageStateValue.WARNING, getUsageState().getSmsExecState())); + + long smsDisableCount = MAX_SMS_ENABLE_VALUE - smsWarnThreshold; + + for (int i = 0; i < smsDisableCount; i++) { + apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.SMS_EXEC_COUNT); + } + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertEquals(ApiUsageStateValue.DISABLED, getUsageState().getSmsExecState())); + } + + private ApiUsageState getUsageState() { + return apiUsageStateService.findTenantApiUsageState(tenantId); + } + private TenantProfile createTenantProfile() { TenantProfile tenantProfile = new TenantProfile(); tenantProfile.setName("Tenant Profile"); @@ -116,6 +149,8 @@ public class ApiUsageTest extends AbstractControllerTest { TenantProfileData tenantProfileData = new TenantProfileData(); DefaultTenantProfileConfiguration config = DefaultTenantProfileConfiguration.builder() .maxDPStorageDays(MAX_DP_ENABLE_VALUE) + .maxSms(MAX_SMS_ENABLE_VALUE) + .smsEnabled(true) .warnThreshold(WARN_THRESHOLD_VALUE) .build(); 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/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java index cbde708ff8..02b063a499 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java @@ -234,6 +234,8 @@ public class RelatedEntitiesAggregationCalculatedFieldStateTest { config.setUseLatestTs(true); + config.setScheduledUpdateInterval(10); + calculatedField.setConfiguration(config); calculatedField.setVersion(1L); return calculatedField; diff --git a/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java index 4bbb51068c..3bf5fa2f25 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java @@ -38,12 +38,30 @@ import org.thingsboard.rule.engine.rest.TbSendRestApiCallReplyNode; import org.thingsboard.rule.engine.telemetry.TbCalculatedFieldsNode; import org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode; import org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; @@ -278,4 +296,167 @@ public class EdgeMsgConstructorUtilsTest { edgeEvent.setBody(body); return edgeEvent; } + + @Test + public void testConstructAssetUpdatedMsg_versionIsReset() { + Asset asset = new Asset(); + asset.setId(new AssetId(UUID.randomUUID())); + asset.setName("Test Asset"); + asset.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructAssetUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, asset).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Asset version should be null in serialized message"); + } + + @Test + public void testConstructAssetProfileUpdatedMsg_versionIsReset() { + AssetProfile assetProfile = new AssetProfile(); + assetProfile.setId(new AssetProfileId(UUID.randomUUID())); + assetProfile.setName("Test Asset Profile"); + assetProfile.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructAssetProfileUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, assetProfile).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "AssetProfile version should be null in serialized message"); + } + + @Test + public void testConstructCustomerUpdatedMsg_versionIsReset() { + Customer customer = new Customer(); + customer.setId(new CustomerId(UUID.randomUUID())); + customer.setTitle("Test Customer"); + customer.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructCustomerUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, customer).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Customer version should be null in serialized message"); + } + + @Test + public void testConstructDashboardUpdatedMsg_versionIsReset() { + Dashboard dashboard = new Dashboard(); + dashboard.setId(new DashboardId(UUID.randomUUID())); + dashboard.setTitle("Test Dashboard"); + dashboard.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructDashboardUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, dashboard).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Dashboard version should be null in serialized message"); + } + + @Test + public void testConstructDeviceUpdatedMsg_versionIsReset() { + Device device = new Device(); + device.setId(new DeviceId(UUID.randomUUID())); + device.setName("Test Device"); + device.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructDeviceUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, device).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Device version should be null in serialized message"); + } + + @Test + public void testConstructDeviceCredentialsUpdatedMsg_versionIsReset() { + DeviceCredentials credentials = new DeviceCredentials(); + credentials.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructDeviceCredentialsUpdatedMsg(credentials).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "DeviceCredentials version should be null in serialized message"); + } + + @Test + public void testConstructEntityViewUpdatedMsg_versionIsReset() { + EntityView entityView = new EntityView(); + entityView.setId(new EntityViewId(UUID.randomUUID())); + entityView.setName("Test EntityView"); + entityView.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructEntityViewUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, entityView).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "EntityView version should be null in serialized message"); + } + + @Test + public void testConstructRelationUpdatedMsg_versionIsReset() { + EntityRelation relation = new EntityRelation(); + relation.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructRelationUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, relation).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "EntityRelation version should be null in serialized message"); + } + + @Test + public void testConstructRuleChainUpdatedMsg_versionIsReset() { + RuleChain ruleChain = new RuleChain(); + ruleChain.setId(new org.thingsboard.server.common.data.id.RuleChainId(UUID.randomUUID())); + ruleChain.setName("Test RuleChain"); + ruleChain.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructRuleChainUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, ruleChain, false).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "RuleChain version should be null in serialized message"); + } + + @Test + public void testConstructTenantUpdateMsg_versionIsReset() { + Tenant tenant = new Tenant(); + tenant.setId(TenantId.fromUUID(UUID.randomUUID())); + tenant.setTitle("Test Tenant"); + tenant.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructTenantUpdateMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, tenant).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Tenant version should be null in serialized message"); + } + + @Test + public void testConstructUserUpdatedMsg_versionIsReset() { + User user = new User(); + user.setId(new UserId(UUID.randomUUID())); + user.setEmail("test@test.com"); + user.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructUserUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, user).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "User version should be null in serialized message"); + } + + @Test + public void testConstructRuleChainMetadataUpdatedMsg_versionIsReset() { + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructRuleChainMetadataUpdatedMsg( + UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, metaData, EdgeVersion.V_4_0_0).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "RuleChainMetaData version should be null in serialized message"); + } } 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/job/JobManagerTest.java b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java index c17a9f6bfb..63fa4a7da4 100644 --- a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.job; +import com.google.common.util.concurrent.SettableFuture; import lombok.SneakyThrows; import org.junit.After; import org.junit.Before; @@ -36,6 +37,7 @@ import org.thingsboard.server.common.data.job.JobType; import org.thingsboard.server.common.data.job.task.DummyTaskResult; import org.thingsboard.server.common.data.job.task.DummyTaskResult.DummyTaskFailure; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.job.JobDao; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -44,10 +46,12 @@ import org.thingsboard.server.queue.task.JobStatsService; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.concurrent.ExecutionException; 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.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; @@ -518,19 +522,114 @@ public class JobManagerTest extends AbstractControllerTest { }); } + @Test + public void testSubmitJob_finishCallback_success() { + SettableFuture future = SettableFuture.create(); + + int tasksCount = 3; + submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(tasksCount) + .taskProcessingTimeMs(100) + .build(), "test-job", TbCallback.wrap(future)); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThat(future.get()).isNull(); + }); + } + + @Test + public void testSubmitJob_finishCallback_taskFailure() { + SettableFuture future = SettableFuture.create(); + + submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(1) + .failedTasksCount(2) + .errors(List.of("task error")) + .retries(0) + .taskProcessingTimeMs(100) + .build(), "test-job", TbCallback.wrap(future)); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .cause() + .hasMessage("task error; task error"); + }); + } + + @Test + public void testSubmitJob_finishCallback_generalError() { + SettableFuture future = SettableFuture.create(); + + submitJob(DummyJobConfiguration.builder() + .generalError("Something went wrong") + .submittedTasksBeforeGeneralError(0) + .build(), "test-job", TbCallback.wrap(future)); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .cause() + .hasMessage("Something went wrong"); + }); + } + + @Test + public void testSubmitJob_finishCallback_cancelled() throws Exception { + SettableFuture future = SettableFuture.create(); + + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(200) + .taskProcessingTimeMs(50) + .build(), "test-job", TbCallback.wrap(future)).getId(); + + Thread.sleep(500); + cancelJob(jobId); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .cause() + .hasMessage("The task was cancelled"); + }); + } + + @Test + public void testSubmitJob_finishCallback_zeroTasks() { + SettableFuture future = SettableFuture.create(); + + submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(0) + .build(), "test-job", TbCallback.wrap(future)); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThat(future.get()).isNull(); + }); + } + private Job submitJob(DummyJobConfiguration configuration) { return submitJob(configuration, "test-job"); } @SneakyThrows private Job submitJob(DummyJobConfiguration configuration, String key) { + return submitJob(configuration, key, null); + } + + @SneakyThrows + private Job submitJob(DummyJobConfiguration configuration, String key, TbCallback callback) { return jobManager.submitJob(Job.builder() .tenantId(tenantId) .type(JobType.DUMMY) .key(key) .entityId(jobEntity.getId()) .configuration(configuration) - .build()).get(); + .build(), callback).get(); } private List getFailures(JobResult jobResult) { diff --git a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java index f5607415fa..cf99669777 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java @@ -188,8 +188,7 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest rule.setTriggerType(triggerConfig.getTriggerType()); rule.setTriggerConfig(triggerConfig); - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(triggerConfig.getTriggerType()); + DefaultNotificationRuleRecipientsConfig recipientsConfig = DefaultNotificationRuleRecipientsConfig.forTriggerType(triggerConfig.getTriggerType()); recipientsConfig.setTargets(DaoUtil.toUUIDs(targets)); rule.setRecipientsConfig(recipientsConfig); diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index e3342dbf43..94af26d3d7 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -211,7 +211,6 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { notificationRule.setTriggerConfig(triggerConfig); EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ALARM); Map> escalationTable = new HashMap<>(); recipientsConfig.setEscalationTable(escalationTable); Map clients = new HashMap<>(); @@ -329,7 +328,6 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { notificationRule.setTriggerConfig(triggerConfig); EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ALARM); Map> escalationTable = new HashMap<>(); recipientsConfig.setEscalationTable(escalationTable); @@ -640,8 +638,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { EntityActionNotificationRuleTriggerConfig triggerConfig = new EntityActionNotificationRuleTriggerConfig(); rule.setTriggerConfig(triggerConfig); - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); + DefaultNotificationRuleRecipientsConfig recipientsConfig = DefaultNotificationRuleRecipientsConfig.forTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); recipientsConfig.setTargets(List.of(createNotificationTarget(tenantAdminUserId).getUuidId())); rule.setRecipientsConfig(recipientsConfig); rule = saveNotificationRule(rule); @@ -671,8 +668,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { triggerConfig.setCreated(true); rule.setTriggerConfig(triggerConfig); NotificationTarget target = createNotificationTarget(tenantAdminUserId); - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); + DefaultNotificationRuleRecipientsConfig recipientsConfig = DefaultNotificationRuleRecipientsConfig.forTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); recipientsConfig.setTargets(List.of(target.getUuidId())); rule.setRecipientsConfig(recipientsConfig); rule = saveNotificationRule(rule); 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/resource/DefaultResourceDataCacheTest.java b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java index c227ec76aa..d33f1755d5 100644 --- a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.resource; +import org.awaitility.Awaitility; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; @@ -29,6 +30,8 @@ import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.concurrent.TimeUnit; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.timeout; @@ -61,6 +64,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest { TbResourceInfo savedResource = tbResourceService.save(resource); verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId()); + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNotNull()); TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); assertThat(cachedData.getData()).isEqualTo(data); assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor); @@ -76,8 +81,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest { TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId()); tbResourceService.delete(resourceById, true, null); verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId()); - TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); - assertThat(cachedDataAfterDeletion).isEqualTo(null); + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNull()); } } 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/sms/DefaultSmsServiceTest.java b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java index 445be6363a..28ce4ea46a 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java @@ -52,6 +52,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @TestPropertySource(properties = { "usage.stats.report.enabled=true", "usage.stats.report.interval=1", + "usage.stats.report.urgent_interval=1" }) public class DefaultSmsServiceTest extends AbstractControllerTest { @SpyBean diff --git a/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java b/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java index 31283a1165..e207c2325c 100644 --- a/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java @@ -41,6 +41,7 @@ import static org.awaitility.Awaitility.await; @TestPropertySource(properties = { "usage.stats.report.enabled=true", "usage.stats.report.interval=2", + "usage.stats.report.urgent_interval=1", "usage.stats.gauge_report_interval=1", "usage.stats.devices.report_interval=3", "state.defaultStateCheckIntervalInSec=3", 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/common/actor/pom.xml b/common/actor/pom.xml index 152712dc51..6d46c10198 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index 5e99ac5100..aa1438d97a 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common 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/cluster-api/pom.xml b/common/cluster-api/pom.xml index 83c066b588..e8968e2a2c 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index bb78733b76..5314510d52 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common 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/pom.xml b/common/dao-api/pom.xml index 0c2291147d..30eb360490 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java index 3166c64835..c5e359e2d5 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java @@ -27,9 +27,6 @@ import java.util.Collection; import java.util.List; import java.util.Optional; -/** - * @author Andrew Shvayka - */ public interface AttributesService { ListenableFuture> find(TenantId tenantId, EntityId entityId, AttributeScope scope, String attributeKey); @@ -48,7 +45,13 @@ public interface AttributesService { List findAllKeysByEntityIds(TenantId tenantId, List entityIds); - List findAllKeysByEntityIds(TenantId tenantId, List entityIds, AttributeScope scope); + List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope); + + ListenableFuture> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope); + + List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope); + + ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope); int removeAllByEntityId(TenantId tenantId, EntityId entityId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java index df5833576a..07ea2243dc 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.audit; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.validation.constraints.NotNull; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; @@ -43,7 +44,7 @@ public interface AuditLogService { CustomerId customerId, UserId userId, String userName, - I entityId, + @NotNull I entityId, E entity, ActionType actionType, Exception e, Object... additionalInfo); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java index 3a67c31b57..fdd722a266 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.entity; import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; @@ -51,4 +52,6 @@ public interface EntityService { PageData findEntityDataByQuery(TenantId tenantId, CustomerId customerId, EntityDataQuery query); + ListenableFuture> findEntityDataByQueryAsync(TenantId tenantId, CustomerId customerId, EntityDataQuery query); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java index 798549cf85..cdb8c23c41 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java @@ -24,10 +24,14 @@ import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.dao.entity.EntityDaoService; +import java.util.List; + public interface ApiKeyService extends EntityDaoService { ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKey); + ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKeyInfo, String value, boolean doValidate); + void deleteApiKey(TenantId tenantId, ApiKey apiKey, boolean force); void deleteByUserId(TenantId tenantId, UserId userId); @@ -38,4 +42,8 @@ public interface ApiKeyService extends EntityDaoService { PageData findApiKeysByUserId(TenantId tenantId, UserId userId, PageLink pageLink); + List findApiKeysByUserId(TenantId tenantId, UserId userId); + + PageData findApiKeysByTenantId(TenantId tenantId, PageLink pageLink); + } 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/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileService.java index ccac3cc5c1..f8f308c8f4 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileService.java @@ -46,7 +46,7 @@ public interface TenantProfileService extends EntityDaoService { EntityInfo findDefaultTenantProfileInfo(TenantId tenantId); - boolean setDefaultTenantProfile(TenantId tenantId, TenantProfileId tenantProfileId); + TenantProfile setDefaultTenantProfile(TenantId tenantId, TenantProfileId tenantProfileId); void deleteTenantProfiles(TenantId tenantId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java index 5bf2c63176..6d5727474a 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java @@ -30,9 +30,6 @@ import java.util.Collection; import java.util.List; import java.util.Optional; -/** - * @author Andrew Shvayka - */ public interface TimeseriesService { ListenableFuture> findAllByQueries(TenantId tenantId, EntityId entityId, List queries); @@ -65,6 +62,10 @@ public interface TimeseriesService { ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds); + List findLatestByEntityIds(TenantId tenantId, List entityIds); + + ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds); + void cleanup(long systemTtl); } diff --git a/common/data/pom.xml b/common/data/pom.xml index fdf70d1f1c..8579103da7 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java index 5a679ce2cf..ceb657374b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java @@ -25,8 +25,8 @@ public enum ApiUsageRecordKey { RE_EXEC_COUNT(ApiFeature.RE, "ruleEngineExecutionCount", "ruleEngineExecutionLimit", "Rule Engine execution"), JS_EXEC_COUNT(ApiFeature.JS, "jsExecutionCount", "jsExecutionLimit", "JavaScript execution"), TBEL_EXEC_COUNT(ApiFeature.TBEL, "tbelExecutionCount", "tbelExecutionLimit", "Tbel execution"), - EMAIL_EXEC_COUNT(ApiFeature.EMAIL, "emailCount", "emailLimit", "email message"), - SMS_EXEC_COUNT(ApiFeature.SMS, "smsCount", "smsLimit", "SMS message"), + EMAIL_EXEC_COUNT(ApiFeature.EMAIL, "emailCount", "emailLimit", "email message", true, true), + SMS_EXEC_COUNT(ApiFeature.SMS, "smsCount", "smsLimit", "SMS message", true, true), CREATED_ALARMS_COUNT(ApiFeature.ALARM, "createdAlarmsCount", "createdAlarmsLimit", "alarm"), ACTIVE_DEVICES("activeDevicesCount"), INACTIVE_DEVICES("inactiveDevicesCount"); @@ -50,21 +50,24 @@ public enum ApiUsageRecordKey { private final String unitLabel; @Getter private final boolean counter; + @Getter + private final boolean urgent; // urgent keys are reported at a shorter interval for quicker usage state updates ApiUsageRecordKey(ApiFeature apiFeature, String apiCountKey, String apiLimitKey, String unitLabel) { - this(apiFeature, apiCountKey, apiLimitKey, unitLabel, true); + this(apiFeature, apiCountKey, apiLimitKey, unitLabel, true, false); } ApiUsageRecordKey(String apiCountKey) { - this(null, apiCountKey, null, null, false); + this(null, apiCountKey, null, null, false, false); } - ApiUsageRecordKey(ApiFeature apiFeature, String apiCountKey, String apiLimitKey, String unitLabel, boolean counter) { + ApiUsageRecordKey(ApiFeature apiFeature, String apiCountKey, String apiLimitKey, String unitLabel, boolean counter, boolean urgent) { this.apiFeature = apiFeature; this.apiCountKey = apiCountKey; this.apiLimitKey = apiLimitKey; this.unitLabel = unitLabel; this.counter = counter; + this.urgent = urgent; } public static ApiUsageRecordKey[] getKeys(ApiFeature feature) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/BaseDataWithAdditionalInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/BaseDataWithAdditionalInfo.java index da72fb67d7..d854fa3eaf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/BaseDataWithAdditionalInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/BaseDataWithAdditionalInfo.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.validation.NoXss; @@ -35,9 +36,11 @@ import java.util.function.Supplier; * Created by ashvayka on 19.02.18. */ @Slf4j +@Schema public abstract class BaseDataWithAdditionalInfo extends BaseData implements HasAdditionalInfo { @NoXss + @Schema private transient JsonNode additionalInfo; @JsonIgnore private byte[] additionalInfoBytes; 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 61620a77be..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,13 +134,18 @@ 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(); } - @Schema(description = "Additional parameters of the device",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the customer. " + + "May include: 'description' (string), 'homeDashboardId' (string, UUID of the home dashboard), " + + "'homeDashboardHideToolbar' (boolean, whether to hide the dashboard toolbar), " + + "'isPublic' (boolean, whether this is a public customer).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Regional customer\",\"homeDashboardId\":\"784f394c-42b6-435a-983c-b7beff2784f9\",\"homeDashboardHideToolbar\":false,\"isPublic\":false}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java b/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java index 168158d427..2c65a3dfc8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java @@ -33,7 +33,7 @@ import java.util.Optional; import java.util.stream.Collectors; @EqualsAndHashCode(callSuper = true) -@JsonPropertyOrder({"title", "image", "mobileHide", "mobileOrder", "configuration", "name", "resources"}) +@JsonPropertyOrder({"id", "createdTime", "tenantId", "title", "name", "image", "mobileHide", "mobileOrder", "assignedCustomers", "configuration", "resources", "version"}) public class Dashboard extends DashboardInfo implements ExportableEntity { private static final long serialVersionUID = 872682138346187503L; @@ -70,7 +70,7 @@ public class Dashboard extends DashboardInfo implements ExportableEntity 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; } @@ -231,7 +231,12 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL this.softwareId = softwareId; } - @Schema(description = "Additional parameters of the device",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the device. " + + "May include: 'gateway' (boolean, whether the device is a gateway), " + + "'description' (string), " + + "'lastConnectedGateway' (string, UUID of the last gateway that connected this device).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"gateway\":false,\"description\":\"Temperature sensor\",\"lastConnectedGateway\":\"784f394c-42b6-435a-983c-b7beff2784f9\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); 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/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 9ad0ebf9e0..5da4f2cd91 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -116,7 +116,10 @@ public class EntityView extends BaseDataWithAdditionalInfo return super.getCreatedTime(); } - @Schema(description = "Additional parameters of the device", implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the entity view. " + + "May include: 'description' (string).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Temperature readings view\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/FeaturesInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/FeaturesInfo.java index 2a3b95ba0c..92a446fcb6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/FeaturesInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/FeaturesInfo.java @@ -15,13 +15,29 @@ */ package org.thingsboard.server.common.data; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@JsonPropertyOrder({ + "emailEnabled", + "smsEnabled", + "notificationEnabled", + "oauthEnabled", + "twoFaEnabled" +}) +@Schema @Data public class FeaturesInfo { + @JsonProperty("emailEnabled") boolean isEmailEnabled; + @JsonProperty("smsEnabled") boolean isSmsEnabled; + @JsonProperty("notificationEnabled") boolean isNotificationEnabled; + @JsonProperty("oauthEnabled") boolean isOauthEnabled; + @JsonProperty("twoFaEnabled") boolean isTwoFaEnabled; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java index 246fba56c5..647b584666 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java @@ -43,17 +43,17 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp @Schema(description = "JSON object with Tenant Id. Tenant Id of the ota package can't be changed.", accessMode = Schema.AccessMode.READ_ONLY) private TenantId tenantId; - @Schema(description = "JSON object with Device Profile Id. Device Profile Id of the ota package can't be changed.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with Device Profile Id. Device Profile Id of the ota package can't be changed.") private DeviceProfileId deviceProfileId; - @Schema(description = "OTA Package type.", example = "FIRMWARE", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "OTA Package type.", example = "FIRMWARE") private OtaPackageType type; @Length(fieldName = "title") @NoXss - @Schema(description = "OTA Package title.", example = "fw", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "OTA Package title.", example = "fw") private String title; @Length(fieldName = "version") @NoXss - @Schema(description = "OTA Package version.", example = "1.0", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "OTA Package version.", example = "1.0") private String version; @Length(fieldName = "tag") @NoXss @@ -61,7 +61,7 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp private String tag; @Length(fieldName = "url") @NoXss - @Schema(description = "OTA Package url.", example = "http://thingsboard.org/fw/1", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "OTA Package url.", example = "http://thingsboard.org/fw/1") private String url; @Schema(description = "Indicates OTA Package 'has data'. Field is returned from DB ('true' if data exists or url is set). If OTA Package 'has data' is 'false' we can not assign the OTA Package to the Device or Device Profile.", example = "true", accessMode = Schema.AccessMode.READ_ONLY) private boolean hasData; 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/ShortCustomerInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java index 930e4dddca..f1de65f1f8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; @@ -39,9 +40,15 @@ public class ShortCustomerInfo { @NoXss private String title; + @JsonProperty("public") @Schema(description = "Indicates special 'Public' customer used to embed dashboards on public websites.") - @Getter @Setter - private boolean isPublic; + private boolean publicCustomer; + + @JsonProperty("public") + public boolean isPublic() { return publicCustomer; } + + @JsonProperty("public") + public void setPublic(boolean publicCustomer) { this.publicCustomer = publicCustomer; } @Override public boolean equals(Object o) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfo.java index 73bd3cddc4..3fd86d1163 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfo.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -23,6 +24,7 @@ import java.util.List; @Data public class SystemInfo { @Schema(description = "Is monolith.") + @JsonProperty("monolith") private boolean isMonolith; @Schema(description = "System data.") private List systemData; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java index 4365357d2d..4343205b74 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java @@ -28,6 +28,7 @@ import java.io.Serial; import java.util.Base64; import java.util.Optional; +@Schema @Slf4j @Data @EqualsAndHashCode(callSuper = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java index f1cca4e80c..16e95d79ea 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java @@ -46,15 +46,17 @@ public class TbResourceInfo extends BaseData implements HasName, H @Length(fieldName = "title") @Schema(description = "Resource title.", example = "BinaryAppDataContainer id=19 v1.0") private String title; - @Schema(description = "Resource type.", example = "LWM2M_MODEL", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Resource type.", example = "LWM2M_MODEL") private ResourceType resourceType; - @Schema(description = "Resource sub type.", example = "IOT_SVG", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Resource sub type.", example = "IOT_SVG") private ResourceSubType resourceSubType; @NoXss @Length(fieldName = "resourceKey") - @Schema(description = "Resource key.", example = "19_1.0", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Resource key.", example = "19_1.0") private String resourceKey; + @Schema(description = "Whether the resource is public.", example = "false") private boolean isPublic; + @Schema(description = "Public resource key.") private String publicResourceKey; @Schema(description = "Resource search text.", example = "19_1.0:binaryappdatacontainer", accessMode = Schema.AccessMode.READ_ONLY) private String searchText; @@ -63,10 +65,12 @@ public class TbResourceInfo extends BaseData implements HasName, H private String etag; @NoXss @Length(fieldName = "file name") - @Schema(description = "Resource file name.", example = "19.xml", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Resource file name.", example = "19.xml") private String fileName; + @Schema(description = "Resource descriptor.") private JsonNode descriptor; + @Schema(description = "External resource Id used for import/export.") private TbResourceId externalId; public TbResourceInfo() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java b/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java index 005e3204cd..374e6b526a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java @@ -164,7 +164,11 @@ public class Tenant extends ContactBased implements HasTenantId, HasTi return super.getEmail(); } - @Schema(description = "Additional parameters of the device", implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the tenant. " + + "May include: 'description' (string), 'homeDashboardId' (string, UUID of the home dashboard), " + + "'homeDashboardHideToolbar' (boolean, whether to hide the dashboard toolbar).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Main tenant\",\"homeDashboardId\":\"784f394c-42b6-435a-983c-b7beff2784f9\",\"homeDashboardHideToolbar\":true}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/User.java b/common/data/src/main/java/org/thingsboard/server/common/data/User.java index 69ba115af1..e1b40b48f8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/User.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/User.java @@ -91,7 +91,7 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, return super.getCreatedTime(); } - @Schema(description = "JSON object with the Tenant Id.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with the Tenant Id.") public TenantId getTenantId() { return tenantId; } @@ -100,7 +100,7 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, this.tenantId = tenantId; } - @Schema(description = "JSON object with the Customer Id.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with the Customer Id.") public CustomerId getCustomerId() { return customerId; } @@ -161,7 +161,16 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, this.phone = phone; } - @Schema(description = "Additional parameters of the user", implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the user. " + + "May include: 'defaultDashboardId' (string, UUID of the default dashboard), " + + "'defaultDashboardFullscreen' (boolean), " + + "'homeDashboardId' (string, UUID of the home dashboard), " + + "'homeDashboardHideToolbar' (boolean), " + + "'lang' (string, user locale, e.g. 'en_US'), " + + "'authProviderName' (string, name of the authentication provider).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"defaultDashboardId\":\"784f394c-42b6-435a-983c-b7beff2784f9\",\"defaultDashboardFullscreen\":false," + + "\"homeDashboardId\":\"784f394c-42b6-435a-983c-b7beff2784f9\",\"homeDashboardHideToolbar\":true,\"lang\":\"en_US\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); 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/ai/dto/TbChatResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java index a553d66bf0..f1004790d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java @@ -17,8 +17,17 @@ package org.thingsboard.server.common.data.ai.dto; 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; +@Schema( + description = "Response from chat API", + discriminatorProperty = "status", + discriminatorMapping = { + @DiscriminatorMapping(value = "SUCCESS", schema = TbChatResponse.Success.class), + @DiscriminatorMapping(value = "FAILURE", schema = TbChatResponse.Failure.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, property = "status", @@ -33,7 +42,8 @@ public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatRes @Schema( description = "Indicates whether the request was successful or not", - example = "SUCCESS" + example = "SUCCESS", + requiredMode = Schema.RequiredMode.REQUIRED ) String getStatus(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java index 55f35a3960..4edf49045b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java @@ -19,11 +19,18 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import dev.langchain4j.data.message.Content; import dev.langchain4j.data.message.TextContent; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import static org.thingsboard.server.common.data.ai.dto.TbContent.TbTextContent; +@Schema( + discriminatorProperty = "contentType", + discriminatorMapping = { + @DiscriminatorMapping(value = "TEXT", schema = TbTextContent.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, @@ -46,7 +53,8 @@ public sealed interface TbContent permits TbTextContent { } @Schema( - description = "Text-based content part of a user's prompt" + description = "Text-based content part of a user's prompt", + allOf = TbContent.class ) record TbTextContent( @NotBlank diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java index 2d060dbf4a..9e91adb2c0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.ai.dto; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; @@ -28,5 +29,6 @@ public record TbUserMessage( requiredMode = Schema.RequiredMode.REQUIRED, description = "A list of content parts that make up the complete user prompt" ) + @ArraySchema(schema = @Schema(ref = "#/components/schemas/TbContent")) List contents ) {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java index adcc176edc..d036969254 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java @@ -17,6 +17,8 @@ package org.thingsboard.server.common.data.ai.model; 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.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; @@ -55,10 +57,31 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS"), @JsonSubTypes.Type(value = OllamaChatModelConfig.class, name = "OLLAMA") }) +@Schema( + name = "AiModelConfig", + description = "Root configuration for AI models", + discriminatorProperty = "provider", + discriminatorMapping = { + @DiscriminatorMapping(value = "OPENAI", schema = OpenAiChatModelConfig.class), + @DiscriminatorMapping(value = "AZURE_OPENAI", schema = AzureOpenAiChatModelConfig.class), + @DiscriminatorMapping(value = "GOOGLE_AI_GEMINI", schema = GoogleAiGeminiChatModelConfig.class), + @DiscriminatorMapping(value = "GOOGLE_VERTEX_AI_GEMINI", schema = GoogleVertexAiGeminiChatModelConfig.class), + @DiscriminatorMapping(value = "MISTRAL_AI", schema = MistralAiChatModelConfig.class), + @DiscriminatorMapping(value = "ANTHROPIC", schema = AnthropicChatModelConfig.class), + @DiscriminatorMapping(value = "AMAZON_BEDROCK", schema = AmazonBedrockChatModelConfig.class), + @DiscriminatorMapping(value = "GITHUB_MODELS", schema = GitHubModelsChatModelConfig.class), + @DiscriminatorMapping(value = "OLLAMA", schema = OllamaChatModelConfig.class) + } +) public interface AiModelConfig { + @Schema( + description = "AI Provider", + requiredMode = Schema.RequiredMode.REQUIRED + ) AiProvider provider(); + @Schema(hidden = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXTERNAL_PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index ee80230fee..490ae67eb2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +@Schema @Builder public record AmazonBedrockChatModelConfig( + @Schema(ref = "#/components/schemas/AmazonBedrockProviderConfig") @NotNull @Valid AmazonBedrockProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 44b55c4c22..77352b06c3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +@Schema @Builder public record AnthropicChatModelConfig( + @Schema(ref = "#/components/schemas/AnthropicProviderConfig") @NotNull @Valid AnthropicProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index 1e0d6d0c18..d4df82d237 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +@Schema @Builder public record AzureOpenAiChatModelConfig( + @Schema(ref = "#/components/schemas/AzureOpenAiProviderConfig") @NotNull @Valid AzureOpenAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index 606f30599b..264683f7b4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; +@Schema @Builder public record GitHubModelsChatModelConfig( + @Schema(ref = "#/components/schemas/GitHubModelsProviderConfig") @NotNull @Valid GitHubModelsProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index 422c92ba97..82ba3a0295 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; +@Schema @Builder public record GoogleAiGeminiChatModelConfig( + @Schema(ref = "#/components/schemas/GoogleAiGeminiProviderConfig") @NotNull @Valid GoogleAiGeminiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index 3e298e52e2..4d20a240e6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +@Schema @Builder public record GoogleVertexAiGeminiChatModelConfig( + @Schema(ref = "#/components/schemas/GoogleVertexAiGeminiProviderConfig") @NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index 5713217fe9..60c208c181 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; +@Schema @Builder public record MistralAiChatModelConfig( + @Schema(ref = "#/components/schemas/MistralAiProviderConfig") @NotNull @Valid MistralAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java index 3f1856f630..5f7776a0a6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; +@Schema @Builder public record OllamaChatModelConfig( + @Schema(ref = "#/components/schemas/OllamaProviderConfig") @NotNull @Valid OllamaProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index f74292cd76..3845aaa543 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +@Schema @Builder public record OpenAiChatModelConfig( + @Schema(ref = "#/components/schemas/OpenAiProviderConfig") @NotNull @Valid OpenAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java index dc38440a66..17e886e946 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record AmazonBedrockProviderConfig( @NotNull String region, @NotNull String accessKeyId, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java index 6e7abeee84..4108a72439 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record AnthropicProviderConfig( @NotNull String apiKey ) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java index 05d9e99569..6687f8a08e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record AzureOpenAiProviderConfig( @NotNull String endpoint, String serviceVersion, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java index 56409c81ad..529eb2b989 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record GitHubModelsProviderConfig( @NotNull String personalAccessToken ) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java index 3492a1096d..5b20ed3d96 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record GoogleAiGeminiProviderConfig( @NotNull String apiKey ) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java index 0e0928e038..72c4b5e940 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +@Schema public record GoogleVertexAiGeminiProviderConfig( @NotBlank String fileName, // not used on BE, but needed for UI @NotNull String projectId, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java index 46165f9843..b0ca42de16 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record MistralAiProviderConfig( @NotNull String apiKey ) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java index 0a4da6c66c..606cc75b9d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java @@ -17,14 +17,26 @@ package org.thingsboard.server.common.data.ai.provider; 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 jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +@Schema public record OllamaProviderConfig( @NotNull String baseUrl, @NotNull @Valid OllamaAuth auth ) implements AiProviderConfig { + @Schema( + description = "Ollama authentication schemes", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "NONE", schema = OllamaAuth.None.class), + @DiscriminatorMapping(value = "BASIC", schema = OllamaAuth.Basic.class), + @DiscriminatorMapping(value = "TOKEN", schema = OllamaAuth.Token.class) + } + ) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java index 59eabc58a6..900eb6734c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java @@ -16,12 +16,14 @@ package org.thingsboard.server.common.data.ai.provider; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.AssertTrue; import lombok.Builder; import org.apache.commons.lang3.StringUtils; import java.util.Objects; +@Schema @Builder public record OpenAiProviderConfig( String baseUrl, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java index abd1515ff2..9f9bdec49d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java @@ -84,6 +84,7 @@ public class Alarm extends BaseData implements HasName, HasTenantId, Ha @Schema(description = "Timestamp of the alarm assignment, in milliseconds", example = "1634115928465") private long assignTs; @Schema(description = "JSON object with alarm details") + @JsonProperty private transient JsonNode details; @Schema(description = "Propagation flag to specify if alarm should be propagated to parent entities of alarm originator", example = "true") private boolean propagate; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java index 0a011c287f..ef7499fa33 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java @@ -45,7 +45,7 @@ public class AlarmComment extends BaseData implements HasName { private AlarmId alarmId; @Schema(description = "JSON object with User id.", accessMode = Schema.AccessMode.READ_ONLY) private UserId userId; - @Schema(description = "Defines origination of comment. System type means comment was created by TB. OTHER type means comment was created by user.", example = "SYSTEM/OTHER", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Defines origination of comment. System type means comment was created by TB. OTHER type means comment was created by user.", example = "SYSTEM/OTHER") private AlarmCommentType type; @Schema(description = "JSON object with text of comment.") @NoXss @@ -56,7 +56,7 @@ public class AlarmComment extends BaseData implements HasName { @Schema(description = "JSON object with the alarm comment Id. " + "Specify this field to update the alarm comment. " + "Referencing non-existing alarm Id will cause error. " + - "Omit this field to create new alarm.", accessMode = Schema.AccessMode.READ_ONLY) + "Omit this field to create new alarm.") @Override public AlarmCommentId getId() { return super.getId(); 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 93370c0c30..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 @@ -22,8 +22,10 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; 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 @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 1d50f3343c..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 @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotNull; @@ -29,6 +31,14 @@ import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmC import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AnyTimeSchedule; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SIMPLE", schema = SimpleAlarmCondition.class), + @DiscriminatorMapping(value = "DURATION", schema = DurationAlarmCondition.class), + @DiscriminatorMapping(value = "REPEATING", schema = RepeatingAlarmCondition.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ 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 0a4a995ab0..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 @@ -21,8 +21,11 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +import io.swagger.v3.oas.annotations.media.Schema; + import java.util.concurrent.TimeUnit; +@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 28472d0556..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 @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +@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 73dfac9e60..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 @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema public class SimpleAlarmCondition extends AlarmCondition { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java index e02d438c0d..5251f385df 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java @@ -20,7 +20,16 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SIMPLE", schema = SimpleAlarmConditionExpression.class), + @DiscriminatorMapping(value = "TBEL", schema = TbelAlarmConditionExpression.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ 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 448d4586ff..f14e97a704 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 @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression; +import io.swagger.v3.oas.annotations.media.ArraySchema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; @@ -25,9 +26,12 @@ import org.thingsboard.server.common.data.alarm.rule.condition.expression.predic import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; import org.thingsboard.server.common.data.query.EntityKeyValueType; +import io.swagger.v3.oas.annotations.media.Schema; + import java.io.Serializable; import java.util.List; +@Schema @Data public class AlarmConditionFilter implements Serializable { @@ -36,6 +40,7 @@ public class AlarmConditionFilter implements Serializable { @NotNull private EntityKeyValueType valueType; private ComplexOperation operation; + @ArraySchema(schema = @Schema(ref = "#/components/schemas/AlarmRuleKeyFilterPredicate")) @Valid @NotEmpty private List predicates; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java index 492fc683dd..9ed265f3b8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression; +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/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java index ec5407b19d..390bbb3230 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; 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 15731ae1fb..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 @@ -18,8 +18,10 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +@Schema(name = "AlarmRuleBooleanFilterPredicate") @Data public class BooleanFilterPredicate implements SimpleKeyFilterPredicate { @@ -29,11 +31,13 @@ public class BooleanFilterPredicate implements SimpleKeyFilterPredicate @NotNull private AlarmConditionValue value; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { 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/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java index 5fa7c17107..17c5279b3e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java @@ -15,17 +15,22 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import java.util.List; +@Schema(name = "AlarmRuleComplexFilterPredicate") @Data public class ComplexFilterPredicate implements KeyFilterPredicate { private ComplexOperation operation; + @ArraySchema(schema = @Schema(ref = "#/components/schemas/AlarmRuleKeyFilterPredicate")) private List predicates; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { return FilterPredicateType.COMPLEX; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java index 3b5fa638d8..de05017130 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "AlarmRuleFilterPredicateType") public enum FilterPredicateType { STRING, NUMERIC, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java index 24f7a6acf6..d6409d5d67 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java @@ -19,9 +19,23 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; +@Schema( + name = "AlarmRuleKeyFilterPredicate", + description = "Filter predicate for alarm rule key-based filtering", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "STRING", schema = StringFilterPredicate.class), + @DiscriminatorMapping(value = "NUMERIC", schema = NumericFilterPredicate.class), + @DiscriminatorMapping(value = "BOOLEAN", schema = BooleanFilterPredicate.class), + @DiscriminatorMapping(value = "NO_DATA", schema = NoDataFilterPredicate.class), + @DiscriminatorMapping(value = "COMPLEX", schema = ComplexFilterPredicate.class) + } +) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @Type(value = StringFilterPredicate.class, name = "STRING"), 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 d6f733d2c1..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 @@ -20,10 +20,12 @@ import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import java.util.concurrent.TimeUnit; +@Schema @Data @AllArgsConstructor @NoArgsConstructor @@ -35,6 +37,7 @@ public class NoDataFilterPredicate implements KeyFilterPredicate { @NotNull private AlarmConditionValue duration; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { return FilterPredicateType.NO_DATA; 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 5622836c93..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 @@ -20,8 +20,10 @@ import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +@Schema(name = "AlarmRuleNumericFilterPredicate") @Data @AllArgsConstructor @NoArgsConstructor @@ -33,11 +35,13 @@ public class NumericFilterPredicate implements SimpleKeyFilterPredicate @NotNull private AlarmConditionValue value; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { 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/SimpleKeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java index 952a5c2d46..1d2a3785db 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +@Schema(name = "AlarmRuleSimpleKeyFilterPredicate") public interface SimpleKeyFilterPredicate extends KeyFilterPredicate { AlarmConditionValue getValue(); 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 1053f21271..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 @@ -18,8 +18,10 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +@Schema(name = "AlarmRuleStringFilterPredicate") @Data public class StringFilterPredicate implements SimpleKeyFilterPredicate { @@ -30,11 +32,13 @@ public class StringFilterPredicate implements SimpleKeyFilterPredicate { private AlarmConditionValue value; private boolean ignoreCase; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { 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 e764a9fb46..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 @@ -21,8 +21,19 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; + import java.io.Serializable; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "ANY_TIME", schema = AnyTimeSchedule.class), + @DiscriminatorMapping(value = "SPECIFIC_TIME", schema = SpecificTimeSchedule.class), + @DiscriminatorMapping(value = "CUSTOM", schema = CustomTimeSchedule.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ 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 73e9e80159..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 @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; +import io.swagger.v3.oas.annotations.media.Schema; + +@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 2d0454c16e..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 @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; +import io.swagger.v3.oas.annotations.media.Schema; + +@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 bbb1fd0390..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 @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; +@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 47d60e732b..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 @@ -17,8 +17,11 @@ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; import lombok.Data; +import io.swagger.v3.oas.annotations.media.Schema; + import java.io.Serializable; +@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 733081b81f..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 @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.Set; +@Schema @Data public class SpecificTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java index ad53f070f6..000f99f303 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java @@ -169,7 +169,10 @@ public class Asset extends BaseDataWithAdditionalInfo implements HasLab this.assetProfileId = assetProfileId; } - @Schema(description = "Additional parameters of the asset",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the asset. " + + "May include: 'description' (string).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Building A asset\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); 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/audit/ActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java index fbb5e9cac7..27502eb70e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java @@ -22,42 +22,203 @@ import java.util.Optional; public enum ActionType { - ADDED(TbMsgType.ENTITY_CREATED), // log entity - DELETED(TbMsgType.ENTITY_DELETED), // log string id - UPDATED(TbMsgType.ENTITY_UPDATED), // log entity - ATTRIBUTES_UPDATED(TbMsgType.ATTRIBUTES_UPDATED), // log attributes/values - ATTRIBUTES_DELETED(TbMsgType.ATTRIBUTES_DELETED), // log attributes - TIMESERIES_UPDATED(TbMsgType.TIMESERIES_UPDATED), // log timeseries update - TIMESERIES_DELETED(TbMsgType.TIMESERIES_DELETED), // log timeseries - RPC_CALL, // log method and params - CREDENTIALS_UPDATED, // log new credentials - ASSIGNED_TO_CUSTOMER(TbMsgType.ENTITY_ASSIGNED), // log customer name - UNASSIGNED_FROM_CUSTOMER(TbMsgType.ENTITY_UNASSIGNED), // log customer name - ACTIVATED, // log string id - SUSPENDED, // log string id - CREDENTIALS_READ(true), // log device id - ATTRIBUTES_READ(true), // log attributes + /** + * Entity created. Pushes {@link TbMsgType#ENTITY_CREATED} to rule engine. + * Audit log payload: full entity JSON. + */ + ADDED(TbMsgType.ENTITY_CREATED), + /** + * Entity deleted. Pushes {@link TbMsgType#ENTITY_DELETED} to rule engine. + * Audit log payload: entity string id. + */ + DELETED(TbMsgType.ENTITY_DELETED), + /** + * Entity updated. Pushes {@link TbMsgType#ENTITY_UPDATED} to rule engine. + * Audit log payload: full entity JSON. + */ + UPDATED(TbMsgType.ENTITY_UPDATED), + /** + * Server-side or shared attributes updated via API. + * Pushes {@link TbMsgType#ATTRIBUTES_UPDATED} to rule engine. + * Rule engine msg metadata includes {@code scope} ({@code SERVER_SCOPE} or {@code SHARED_SCOPE}). + * Rule engine msg data: key-value pairs of the updated attributes. + * Audit log payload: updated attributes and their values. + */ + ATTRIBUTES_UPDATED(TbMsgType.ATTRIBUTES_UPDATED), + /** + * Attributes deleted via API. + * Pushes {@link TbMsgType#ATTRIBUTES_DELETED} to rule engine. + * Rule engine msg metadata includes {@code scope} ({@code SERVER_SCOPE} or {@code SHARED_SCOPE}). + * Rule engine msg data: {@code {"attributes": ["key1", "key2"]}}. + * Audit log payload: list of deleted attribute keys. + */ + ATTRIBUTES_DELETED(TbMsgType.ATTRIBUTES_DELETED), + /** + * Timeseries data saved via API (not from device transport). + * Pushes {@link TbMsgType#TIMESERIES_UPDATED} to rule engine. + * Rule engine msg data: {@code {"timeseries": [{"ts": ..., "values": {...}}, ...]}}. + * Audit log payload: timeseries entries. + */ + TIMESERIES_UPDATED(TbMsgType.TIMESERIES_UPDATED), + /** + * Timeseries data deleted via API. + * Pushes {@link TbMsgType#TIMESERIES_DELETED} to rule engine. + * Rule engine msg data: {@code {"timeseries": ["key1", ...], "startTs": ..., "endTs": ...}}. + * Audit log payload: deleted timeseries keys. + */ + TIMESERIES_DELETED(TbMsgType.TIMESERIES_DELETED), + /** + * RPC call to device. Does not push to rule engine (RPC has its own lifecycle messages). + * Audit log payload: RPC method and params. + */ + RPC_CALL, + /** + * Device credentials updated. Does not push to rule engine. + * Audit log payload: new credentials value. + */ + CREDENTIALS_UPDATED, + /** + * Entity assigned to a customer. Pushes {@link TbMsgType#ENTITY_ASSIGNED} to rule engine. + * Rule engine msg metadata includes {@code assignedCustomerId} and {@code assignedCustomerName}. + * Audit log payload: customer name. + */ + ASSIGNED_TO_CUSTOMER(TbMsgType.ENTITY_ASSIGNED), + /** + * Entity unassigned from a customer. Pushes {@link TbMsgType#ENTITY_UNASSIGNED} to rule engine. + * Rule engine msg metadata includes {@code unassignedCustomerId} and {@code unassignedCustomerName}. + * Audit log payload: customer name. + */ + UNASSIGNED_FROM_CUSTOMER(TbMsgType.ENTITY_UNASSIGNED), + /** + * User account or integration activated. Does not push to rule engine. + * Audit log payload: entity string id. + */ + ACTIVATED, + /** + * User account or integration suspended. Does not push to rule engine. + * Audit log payload: entity string id. + */ + SUSPENDED, + /** + * Device credentials read. Read-only action. Does not push to rule engine. + * Audit log payload: device id. + */ + CREDENTIALS_READ(true), + /** + * Attributes read. Read-only action. Does not push to rule engine. + * Audit log payload: attribute keys read. + */ + ATTRIBUTES_READ(true), + /** + * Relation created or updated. Pushes {@link TbMsgType#RELATION_ADD_OR_UPDATE} to rule engine. + * Rule engine msg data: relation JSON ({@code from}, {@code to}, {@code type}, {@code typeGroup}). + */ RELATION_ADD_OR_UPDATE(TbMsgType.RELATION_ADD_OR_UPDATE), + /** + * Relation deleted. Pushes {@link TbMsgType#RELATION_DELETED} to rule engine. + * Rule engine msg data: relation JSON ({@code from}, {@code to}, {@code type}, {@code typeGroup}). + */ RELATION_DELETED(TbMsgType.RELATION_DELETED), + /** + * All relations for an entity deleted. Pushes {@link TbMsgType#RELATIONS_DELETED} to rule engine. + * Rule engine msg data: empty JSON object. + */ RELATIONS_DELETED(TbMsgType.RELATIONS_DELETED), - REST_API_RULE_ENGINE_CALL, // log call to rule engine from REST API + /** + * REST API call to rule engine. Does not push to rule engine directly + * (the REST controller creates a {@link TbMsgType#REST_API_REQUEST} message itself). + * Audit log payload: call details. + */ + REST_API_RULE_ENGINE_CALL, + /** + * Alarm acknowledged by a user. Pushes {@link TbMsgType#ALARM_ACK} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_ACK(TbMsgType.ALARM_ACK, true), + /** + * Alarm cleared by a user. Pushes {@link TbMsgType#ALARM_CLEAR} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_CLEAR(TbMsgType.ALARM_CLEAR, true), + /** + * Alarm deleted by a user. Pushes {@link TbMsgType#ALARM_DELETE} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_DELETE(TbMsgType.ALARM_DELETE, true), + /** + * Alarm assigned to a user. Pushes {@link TbMsgType#ALARM_ASSIGNED} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_ASSIGNED(TbMsgType.ALARM_ASSIGNED, true), + /** + * Alarm unassigned from a user. Pushes {@link TbMsgType#ALARM_UNASSIGNED} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_UNASSIGNED(TbMsgType.ALARM_UNASSIGNED, true), + /** + * User logged in. Does not push to rule engine. + */ LOGIN, + /** + * User logged out. Does not push to rule engine. + */ LOGOUT, + /** + * User account locked out due to too many failed login attempts. Does not push to rule engine. + */ LOCKOUT, + /** + * Entity assigned from another tenant (incoming side of cross-tenant transfer). + * Pushes {@link TbMsgType#ENTITY_ASSIGNED_FROM_TENANT} to rule engine. + * Rule engine msg metadata includes {@code assignedFromTenantId} and {@code assignedFromTenantName}. + */ ASSIGNED_FROM_TENANT(TbMsgType.ENTITY_ASSIGNED_FROM_TENANT), + /** + * Entity assigned to another tenant (outgoing side of cross-tenant transfer). + * Pushes {@link TbMsgType#ENTITY_ASSIGNED_TO_TENANT} to rule engine. + * Rule engine msg metadata includes {@code assignedToTenantId} and {@code assignedToTenantName}. + */ ASSIGNED_TO_TENANT(TbMsgType.ENTITY_ASSIGNED_TO_TENANT), + /** + * Device provisioned successfully. Pushes {@link TbMsgType#PROVISION_SUCCESS} to rule engine. + * Rule engine msg data: full device JSON. + */ PROVISION_SUCCESS(TbMsgType.PROVISION_SUCCESS), + /** + * Device provisioning failed. Pushes {@link TbMsgType#PROVISION_FAILURE} to rule engine. + * Rule engine msg data: full device JSON. + */ PROVISION_FAILURE(TbMsgType.PROVISION_FAILURE), - ASSIGNED_TO_EDGE(TbMsgType.ENTITY_ASSIGNED_TO_EDGE), // log edge name + /** + * Entity assigned to an Edge instance. Pushes {@link TbMsgType#ENTITY_ASSIGNED_TO_EDGE} to rule engine. + * Rule engine msg metadata includes {@code assignedEdgeId} and {@code assignedEdgeName}. + * Audit log payload: edge name. + */ + ASSIGNED_TO_EDGE(TbMsgType.ENTITY_ASSIGNED_TO_EDGE), + /** + * Entity unassigned from an Edge instance. Pushes {@link TbMsgType#ENTITY_UNASSIGNED_FROM_EDGE} to rule engine. + * Rule engine msg metadata includes {@code unassignedEdgeId} and {@code unassignedEdgeName}. + */ UNASSIGNED_FROM_EDGE(TbMsgType.ENTITY_UNASSIGNED_FROM_EDGE), + /** + * Comment added to an alarm. Pushes {@link TbMsgType#COMMENT_CREATED} to rule engine. + * Rule engine msg metadata includes {@code comment} (JSON string of the AlarmComment object). + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ADDED_COMMENT(TbMsgType.COMMENT_CREATED), + /** + * Alarm comment updated. Pushes {@link TbMsgType#COMMENT_UPDATED} to rule engine. + * Rule engine msg metadata includes {@code comment} (JSON string of the AlarmComment object). + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ UPDATED_COMMENT(TbMsgType.COMMENT_UPDATED), + /** + * Alarm comment deleted. Does not push to rule engine. + */ DELETED_COMMENT, + /** + * SMS sent. Does not push to rule engine. + */ SMS_SENT; @Getter 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/cf/CalculatedField.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java index 36fd677299..2d3928ee88 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java @@ -17,15 +17,15 @@ 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 lombok.Getter; -import lombok.Setter; import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.EntityType; +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; @@ -39,7 +39,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import java.io.Serial; import java.util.Collections; import java.util.EnumSet; import java.util.Map; @@ -48,10 +47,7 @@ import java.util.Set; @Schema @Data @EqualsAndHashCode(callSuper = true) -public class CalculatedField extends BaseData implements HasName, HasTenantId, HasVersion, HasDebugSettings { - - @Serial - private static final long serialVersionUID = 4491966747773381420L; +public class CalculatedField extends BaseData implements HasName, HasTenantId, HasVersion, HasDebugSettings, HasAdditionalInfo { public static final Map> SUPPORTED_ENTITIES = Map.of( EntityType.DEVICE, CalculatedFieldType.all, @@ -86,32 +82,21 @@ public class CalculatedField extends BaseData implements HasN private DebugSettings debugSettings; @Schema(description = "Version of calculated field configuration.", example = "0") private int configurationVersion; - @Schema(implementation = SimpleCalculatedFieldConfiguration.class) + @Schema(implementation = CalculatedFieldConfiguration.class) @Valid @NotNull private CalculatedFieldConfiguration configuration; - @Getter - @Setter private Long version; + @NoXss + @Schema(description = "Additional parameters of the calculated field") + private JsonNode additionalInfo; - public CalculatedField() { - super(); - } + public CalculatedField() {} public CalculatedField(CalculatedFieldId id) { super(id); } - public CalculatedField(TenantId tenantId, EntityId entityId, CalculatedFieldType type, String name, int configurationVersion, CalculatedFieldConfiguration configuration, Long version) { - this.tenantId = tenantId; - this.entityId = entityId; - this.type = type; - this.name = name; - this.configurationVersion = configurationVersion; - this.configuration = configuration; - this.version = version; - } - public CalculatedField(CalculatedField calculatedField) { super(calculatedField); this.tenantId = calculatedField.tenantId; @@ -123,6 +108,7 @@ public class CalculatedField extends BaseData implements HasN this.configurationVersion = calculatedField.configurationVersion; this.configuration = calculatedField.configuration; this.version = calculatedField.version; + this.additionalInfo = calculatedField.additionalInfo; } @Schema(description = "JSON object with the Calculated Field Id. Referencing non-existing Calculated Field Id will cause error.") @@ -159,6 +145,7 @@ public class CalculatedField extends BaseData implements HasN .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(']') diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java index ef3869fccb..d22666723b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import lombok.Data; @@ -34,6 +35,7 @@ import java.util.stream.Stream; import static java.util.Map.Entry.comparingByKey; +@Schema @Data public class AlarmCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java index 9c5f84f75b..b41e296cba 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java @@ -16,10 +16,12 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.springframework.lang.Nullable; import org.thingsboard.server.common.data.id.EntityId; +@Schema @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class Argument { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java index 1dd87a3974..852eefc4a4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import org.thingsboard.server.common.data.id.EntityId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java index 2a17232f0f..4fbac188bd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +@Schema @Data @AllArgsConstructor @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java index 02b453b20a..22e566e39d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java @@ -18,7 +18,16 @@ package org.thingsboard.server.common.data.cf.configuration; 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; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "IMMEDIATE", schema = AttributesImmediateOutputStrategy.class), + @DiscriminatorMapping(value = "RULE_CHAIN", schema = AttributesRuleChainOutputStrategy.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java index 5d0b4a3abe..935d97fabc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; +@Schema @Data @NoArgsConstructor public class AttributesRuleChainOutputStrategy implements AttributesOutputStrategy { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java index d2c6ebb78c..0ba9db43cb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.Data; import java.util.Map; +@Schema @Data public abstract class BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java index 18213b0b7b..73d130c16f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; 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.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; @@ -34,6 +36,18 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SIMPLE", schema = SimpleCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "SCRIPT", schema = ScriptCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "GEOFENCING", schema = GeofencingCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "ALARM", schema = AlarmCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "PROPAGATION", schema = PropagationCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "RELATED_ENTITIES_AGGREGATION", schema = RelatedEntitiesAggregationCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "ENTITY_AGGREGATION", schema = EntityAggregationCalculatedFieldConfiguration.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java index 90c4b3fa39..72b7f88bf8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java @@ -19,7 +19,16 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "RELATION_PATH_QUERY", schema = RelationPathQueryDynamicSourceConfiguration.class), + @DiscriminatorMapping(value = "CURRENT_OWNER", schema = CurrentOwnerDynamicSourceConfiguration.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java index 46df6cbad7..2ee6901e9c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java @@ -20,10 +20,19 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; 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.AttributeScope; import java.util.Objects; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "TIME_SERIES", schema = TimeSeriesOutput.class), + @DiscriminatorMapping(value = "ATTRIBUTES", schema = AttributesOutput.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java index d0ae8789b9..7390424b3d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -26,6 +27,7 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel; import java.util.List; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class PropagationCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements HasRelationPathLevel, HasUseLatestTsConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java index 59627751d0..fd36d1bd61 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java @@ -16,10 +16,12 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import org.thingsboard.server.common.data.AttributeScope; +@Schema @Data @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java index 208bb3cc80..b59f69afc1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntityRelation; @@ -28,6 +30,7 @@ import java.util.List; @Data public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration { + @ArraySchema(schema = @Schema(implementation = RelationPathLevel.class)) private List levels; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java index f5b45c6d5c..f78ff72af0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java @@ -22,14 +22,14 @@ public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends Ca boolean isScheduledUpdateEnabled(); @PositiveOrZero - int getScheduledUpdateInterval(); + Integer getScheduledUpdateInterval(); - void setScheduledUpdateInterval(int interval); + void setScheduledUpdateInterval(Integer interval); default void validate(long minAllowedScheduledUpdateInterval) { if (getScheduledUpdateInterval() < minAllowedScheduledUpdateInterval) { - throw new IllegalArgumentException("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval); + throw new IllegalArgumentException("Scheduled update interval (" + getScheduledUpdateInterval() + + " seconds) is less than minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval + " seconds"); } } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java index 464f7f54e4..40c28967b3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @Data +@Schema @EqualsAndHashCode(callSuper = true) public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java index 53210d1cf8..e3ca07b5be 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @Data +@Schema @EqualsAndHashCode(callSuper = true) public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration, HasUseLatestTsConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java index a16b9cab89..942a734908 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class TimeSeriesOutput implements Output { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java index 327cad3d5f..aca968f422 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java @@ -17,7 +17,16 @@ package org.thingsboard.server.common.data.cf.configuration; 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; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "IMMEDIATE", schema = TimeSeriesImmediateOutputStrategy.class), + @DiscriminatorMapping(value = "RULE_CHAIN", schema = TimeSeriesRuleChainOutputStrategy.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java index d4ebb231a2..1d68d3e8ee 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; +@Schema @Data @NoArgsConstructor public class TimeSeriesRuleChainOutputStrategy implements TimeSeriesOutputStrategy { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java index 32db324db9..4c9c0915f3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java @@ -19,7 +19,16 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "key", schema = AggKeyInput.class), + @DiscriminatorMapping(value = "function", schema = AggFunctionInput.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java index d905248aa5..7cb833ab3a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -22,6 +23,7 @@ import lombok.Data; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.HasRelationPathLevel; import org.thingsboard.server.common.data.cf.configuration.HasUseLatestTsConfig; import org.thingsboard.server.common.data.cf.configuration.Output; @@ -30,6 +32,7 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel; import java.util.Map; +@Schema @Data public class RelatedEntitiesAggregationCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration, HasRelationPathLevel, HasUseLatestTsConfig { @@ -45,7 +48,7 @@ public class RelatedEntitiesAggregationCalculatedFieldConfiguration implements A private Output output; private boolean useLatestTs; - private int scheduledUpdateInterval; + private Integer scheduledUpdateInterval; @Override public CalculatedFieldType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java index 53235136bb..70be733928 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -23,6 +24,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; 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.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; @@ -31,6 +33,7 @@ import org.thingsboard.server.common.data.cf.configuration.aggregation.single.in import java.util.Map; +@Schema @Data public class EntityAggregationCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java index 2eada7f165..55ffb0243b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java @@ -19,10 +19,25 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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 java.time.ZoneId; import java.time.ZonedDateTime; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "HOUR", schema = HourInterval.class), + @DiscriminatorMapping(value = "DAY", schema = DayInterval.class), + @DiscriminatorMapping(value = "WEEK", schema = WeekInterval.class), + @DiscriminatorMapping(value = "WEEK_SUN_SAT", schema = WeekSunSatInterval.class), + @DiscriminatorMapping(value = "MONTH", schema = MonthInterval.class), + @DiscriminatorMapping(value = "QUARTER", schema = QuarterInterval.class), + @DiscriminatorMapping(value = "YEAR", schema = YearInterval.class), + @DiscriminatorMapping(value = "CUSTOM", schema = CustomInterval.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java index 3b72f309d8..933f2569fb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -24,6 +25,7 @@ import lombok.NoArgsConstructor; import java.time.Duration; import java.time.ZonedDateTime; +@Schema @EqualsAndHashCode(callSuper = true) @Data @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java index 1ff5ed25c2..23cb648de2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; +@Schema @Data @NoArgsConstructor public class DayInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java index f20387db82..7a95f2d387 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -22,6 +23,7 @@ import lombok.NoArgsConstructor; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; +@Schema @EqualsAndHashCode(callSuper = true) @Data @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java index 3e2dee2eaf..d49ff9b335 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; +@Schema @Data @NoArgsConstructor public class MonthInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java index 6a9123f76f..a6f29f53f4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -22,6 +23,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.ZonedDateTime; +@Schema @Data @NoArgsConstructor public class QuarterInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java index 311a5254ef..fad2f072d3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -23,6 +24,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAdjusters; +@Schema @Data @NoArgsConstructor public class WeekInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java index 732b82446a..a3d5876b0e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -23,6 +24,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAdjusters; +@Schema @Data @NoArgsConstructor public class WeekSunSatInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java index 9f1015f01e..882aaa1e56 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -22,6 +23,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.ZonedDateTime; +@Schema @Data @NoArgsConstructor public class YearInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java index cb408c5dbf..62ece3d2dc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java @@ -16,12 +16,14 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.HasUseLatestTsConfig; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; @@ -36,6 +38,7 @@ import java.util.Set; import static java.util.stream.Collectors.toSet; +@Schema @Data public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration, HasUseLatestTsConfig { @@ -48,7 +51,7 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal private Map zoneGroups; private boolean scheduledUpdateEnabled; - private int scheduledUpdateInterval; + private Integer scheduledUpdateInterval; @NotNull private Output output; @@ -88,6 +91,9 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal @Override public void validate() { + if (scheduledUpdateEnabled && scheduledUpdateInterval == null) { + throw new IllegalArgumentException("Refresh interval is required when periodic zone group refresh is enabled."); + } zoneGroups.forEach((key, value) -> value.validate(key)); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/CoapDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/CoapDeviceTransportConfiguration.java index 3f23e4fe14..86a61e31b6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/CoapDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/CoapDeviceTransportConfiguration.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.device.data; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; @@ -25,6 +26,7 @@ import java.util.HashMap; import java.util.Map; @Data +@Schema public class CoapDeviceTransportConfiguration extends PowerSavingConfiguration implements DeviceTransportConfiguration { private static final long serialVersionUID = 6061442236008925609L; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceConfiguration.java index 85c1d83744..a0040adb4b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceConfiguration.java @@ -19,7 +19,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceProfileType; -@Schema +@Schema(description = "Default device configuration") @Data public class DefaultDeviceConfiguration implements DeviceConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceTransportConfiguration.java index ae7b2e10ab..022a0ca528 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceTransportConfiguration.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.device.data; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; +@Schema @Data public class DefaultDeviceTransportConfiguration implements DeviceTransportConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceConfiguration.java index 9fa449fdfa..6fe689d17c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceConfiguration.java @@ -19,12 +19,19 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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.DeviceProfileType; import java.io.Serializable; -@Schema +@Schema( + description = "Device configuration", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultDeviceConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -34,7 +41,7 @@ import java.io.Serializable; @JsonSubTypes.Type(value = DefaultDeviceConfiguration.class, name = "DEFAULT")}) public interface DeviceConfiguration extends Serializable { - @JsonIgnore + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Device profile type") DeviceProfileType getType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceTransportConfiguration.java index 84c8e3b463..fa7df3392d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceTransportConfiguration.java @@ -19,12 +19,23 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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.DeviceTransportType; import java.io.Serializable; -@Schema +@Schema( + description = "Configuration for device transport", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultDeviceTransportConfiguration.class), + @DiscriminatorMapping(value = "MQTT", schema = MqttDeviceTransportConfiguration.class), + @DiscriminatorMapping(value = "COAP", schema = CoapDeviceTransportConfiguration.class), + @DiscriminatorMapping(value = "LWM2M", schema = Lwm2mDeviceTransportConfiguration.class), + @DiscriminatorMapping(value = "SNMP", schema = SnmpDeviceTransportConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -37,6 +48,7 @@ import java.io.Serializable; @JsonSubTypes.Type(value = Lwm2mDeviceTransportConfiguration.class, name = "LWM2M"), @JsonSubTypes.Type(value = SnmpDeviceTransportConfiguration.class, name = "SNMP")}) public interface DeviceTransportConfiguration extends Serializable { + @JsonIgnore DeviceTransportType getType(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/Lwm2mDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/Lwm2mDeviceTransportConfiguration.java index 079ceffada..656262ab45 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/Lwm2mDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/Lwm2mDeviceTransportConfiguration.java @@ -18,12 +18,14 @@ package org.thingsboard.server.common.data.device.data; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import java.util.HashMap; import java.util.Map; +@Schema @Data public class Lwm2mDeviceTransportConfiguration extends PowerSavingConfiguration implements DeviceTransportConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/MqttDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/MqttDeviceTransportConfiguration.java index 0adb842ec6..bf83fd31fc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/MqttDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/MqttDeviceTransportConfiguration.java @@ -18,12 +18,14 @@ package org.thingsboard.server.common.data.device.data; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import java.util.HashMap; import java.util.Map; +@Schema @Data public class MqttDeviceTransportConfiguration implements DeviceTransportConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerSavingConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerSavingConfiguration.java index 5509845855..21f2b619d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerSavingConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerSavingConfiguration.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.device.data; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serializable; @Data +@Schema public class PowerSavingConfiguration implements Serializable { private static final long serialVersionUID = 2905389805488525362L; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/SnmpDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/SnmpDeviceTransportConfiguration.java index 8357b65f21..5c732b9e62 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/SnmpDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/SnmpDeviceTransportConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.device.data; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.ToString; import org.thingsboard.server.common.data.DeviceTransportType; @@ -24,6 +25,7 @@ import org.thingsboard.server.common.data.transport.snmp.AuthenticationProtocol; import org.thingsboard.server.common.data.transport.snmp.PrivacyProtocol; import org.thingsboard.server.common.data.transport.snmp.SnmpProtocolVersion; +@Schema @Data @ToString(of = {"host", "port", "protocolVersion"}) public class SnmpDeviceTransportConfiguration implements DeviceTransportConfiguration { 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 838239b091..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 @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.Data; @@ -23,14 +24,14 @@ import lombok.Data; import java.io.Serializable; import java.util.List; -@Schema +@Schema(hidden = true) @Data @JsonIgnoreProperties(ignoreUnknown = true) @Deprecated public class AlarmCondition implements Serializable { @Valid - @Schema(description = "JSON array of alarm condition filters") + @ArraySchema(schema = @Schema(ref = "#/components/schemas/AlarmConditionFilter")) private List condition; @Schema(description = "JSON object representing alarm condition type") private AlarmConditionSpec spec; 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/AlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java index d3066ec8cc..fd6a12d223 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java @@ -19,9 +19,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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 java.io.Serializable; +@Schema( + description = "Specification for alarm conditions", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SIMPLE", schema = SimpleAlarmConditionSpec.class), + @DiscriminatorMapping(value = "DURATION", schema = DurationAlarmConditionSpec.class), + @DiscriminatorMapping(value = "REPEATING", schema = RepeatingAlarmConditionSpec.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, 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 d46fedf87b..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,10 +18,14 @@ 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.Schema; import org.thingsboard.server.common.data.query.DynamicValue; import java.io.Serializable; +@Schema( + hidden = true +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, 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/AllowCreateNewDevicesDeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreateNewDevicesDeviceProfileProvisionConfiguration.java index b33b56ba82..d1672af07e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreateNewDevicesDeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreateNewDevicesDeviceProfileProvisionConfiguration.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 org.thingsboard.server.common.data.DeviceProfileProvisionType; @Data +@Schema public class AllowCreateNewDevicesDeviceProfileProvisionConfiguration implements DeviceProfileProvisionConfiguration { private final String provisionDeviceSecret; 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/CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.java index 7a1d6d1a95..935da4cbbe 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.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 org.thingsboard.server.common.data.DeviceProfileProvisionType; @Data +@Schema public class CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration implements DeviceProfileProvisionConfiguration { private final String provisionDeviceSecret; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceProfileTransportConfiguration.java index b12d0bdf40..66d7f15fc4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceProfileTransportConfiguration.java @@ -15,14 +15,18 @@ */ 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.DeviceTransportType; import org.thingsboard.server.common.data.device.data.PowerSavingConfiguration; +@Schema @Data public class CoapDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { + @Schema(implementation = CoapDeviceTypeConfiguration.class) private CoapDeviceTypeConfiguration coapDeviceTypeConfiguration; + @Schema private PowerSavingConfiguration clientSettings; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceTypeConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceTypeConfiguration.java index 66907288e9..12efa4a14c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceTypeConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceTypeConfiguration.java @@ -19,10 +19,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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.CoapDeviceType; import java.io.Serializable; +@Schema( + description = "CoAP device type configuration", + discriminatorProperty = "coapDeviceType", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultCoapDeviceTypeConfiguration.class), + @DiscriminatorMapping(value = "EFENTO", schema = EfentoCoapDeviceTypeConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, 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/DefaultCoapDeviceTypeConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultCoapDeviceTypeConfiguration.java index 716f733a0a..0455f9f370 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultCoapDeviceTypeConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultCoapDeviceTypeConfiguration.java @@ -15,14 +15,17 @@ */ 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.CoapDeviceType; @Data +@Schema public class DefaultCoapDeviceTypeConfiguration implements CoapDeviceTypeConfiguration { private static final long serialVersionUID = -4287100699186773773L; + @Schema private TransportPayloadTypeConfiguration transportPayloadTypeConfiguration; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileConfiguration.java index 5058024680..13cd6b4a58 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileConfiguration.java @@ -15,9 +15,11 @@ */ 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.DeviceProfileType; +@Schema(description = "Default device profile configuration") @Data public class DefaultDeviceProfileConfiguration implements DeviceProfileConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileTransportConfiguration.java index 012b09c8e1..f00922119a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileTransportConfiguration.java @@ -15,9 +15,11 @@ */ 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.DeviceTransportType; +@Schema @Data public class DefaultDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileConfiguration.java index 98546180e2..69c788539b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileConfiguration.java @@ -19,10 +19,19 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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.DeviceProfileType; import java.io.Serializable; +@Schema( + description = "Device profile configuration", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultDeviceProfileConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, 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 636f546386..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 @@ -36,7 +36,7 @@ public class DeviceProfileData implements Serializable { @Schema(description = "JSON object of provisioning strategy type per device profile") private DeviceProfileProvisionConfiguration provisionConfiguration; @Valid - @Schema(description = "JSON array of alarm rules configuration per device profile") + @Schema(hidden = true) private List alarms; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java index 45ac5207f5..b94c2c9a28 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java @@ -19,10 +19,22 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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.DeviceProfileProvisionType; import java.io.Serializable; +@Schema( + description = "Device profile provision configuration", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DISABLED", schema = DisabledDeviceProfileProvisionConfiguration.class), + @DiscriminatorMapping(value = "ALLOW_CREATE_NEW_DEVICES", schema = AllowCreateNewDevicesDeviceProfileProvisionConfiguration.class), + @DiscriminatorMapping(value = "CHECK_PRE_PROVISIONED_DEVICES", schema = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class), + @DiscriminatorMapping(value = "X509_CERTIFICATE_CHAIN", schema = X509CertificateChainProvisionConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -35,6 +47,7 @@ import java.io.Serializable; @JsonSubTypes.Type(value = X509CertificateChainProvisionConfiguration.class, name = "X509_CERTIFICATE_CHAIN")}) public interface DeviceProfileProvisionConfiguration extends Serializable { + @Schema(description = "Provision device secret", example = "secret123") String getProvisionDeviceSecret(); @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileTransportConfiguration.java index 17627ecc6b..ea035248ac 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileTransportConfiguration.java @@ -19,10 +19,23 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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.DeviceTransportType; import java.io.Serializable; +@Schema( + description = "Configuration for device profile transport", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultDeviceProfileTransportConfiguration.class), + @DiscriminatorMapping(value = "MQTT", schema = MqttDeviceProfileTransportConfiguration.class), + @DiscriminatorMapping(value = "LWM2M", schema = Lwm2mDeviceProfileTransportConfiguration.class), + @DiscriminatorMapping(value = "COAP", schema = CoapDeviceProfileTransportConfiguration.class), + @DiscriminatorMapping(value = "SNMP", schema = SnmpDeviceProfileTransportConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DisabledDeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DisabledDeviceProfileProvisionConfiguration.java index 7492c077c8..df5883230a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DisabledDeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DisabledDeviceProfileProvisionConfiguration.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 org.thingsboard.server.common.data.DeviceProfileProvisionType; @Data +@Schema public class DisabledDeviceProfileProvisionConfiguration implements DeviceProfileProvisionConfiguration { private final String provisionDeviceSecret; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java index 202c11019c..b40b8f2c82 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java @@ -16,17 +16,21 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.query.FilterPredicateValue; import java.util.concurrent.TimeUnit; +@Schema(description = "Duration Alarm Condition Specification") @Data @JsonIgnoreProperties(ignoreUnknown = true) @Deprecated public class DurationAlarmConditionSpec implements AlarmConditionSpec { + @Schema(description = "Duration time unit") private TimeUnit unit; + @Schema(description = "Duration predicate") private FilterPredicateValue predicate; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/EfentoCoapDeviceTypeConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/EfentoCoapDeviceTypeConfiguration.java index b5d6bc36f4..8d6831ebd9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/EfentoCoapDeviceTypeConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/EfentoCoapDeviceTypeConfiguration.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 org.thingsboard.server.common.data.CoapDeviceType; @Data +@Schema public class EfentoCoapDeviceTypeConfiguration implements CoapDeviceTypeConfiguration { private static final long serialVersionUID = -8523081152598707064L; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/JsonTransportPayloadConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/JsonTransportPayloadConfiguration.java index 16d5ca7686..3103aff8c4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/JsonTransportPayloadConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/JsonTransportPayloadConfiguration.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 org.thingsboard.server.common.data.TransportPayloadType; @Data +@Schema public class JsonTransportPayloadConfiguration implements TransportPayloadTypeConfiguration { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java index d73e4bf1fe..300ede57c9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java @@ -15,6 +15,8 @@ */ 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 lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.device.data.PowerMode; @@ -22,20 +24,25 @@ import org.thingsboard.server.common.data.device.profile.lwm2m.OtherConfiguratio import org.thingsboard.server.common.data.device.profile.lwm2m.TelemetryMappingConfiguration; import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MBootstrapServerCredential; -import static org.eclipse.leshan.core.LwM2m.Version.V1_0; -import static org.thingsboard.server.common.data.device.profile.lwm2m.TelemetryObserveStrategy.SINGLE; - import java.util.Collections; import java.util.List; +import static org.eclipse.leshan.core.LwM2m.Version.V1_0; +import static org.thingsboard.server.common.data.device.profile.lwm2m.TelemetryObserveStrategy.SINGLE; + @Data +@Schema public class Lwm2mDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { private static final long serialVersionUID = 6257277825459600068L; + @Schema(description = "Configuration for mapping LwM2M resources to telemetry and attributes") private TelemetryMappingConfiguration observeAttr; + @Schema(description = "Flag indicating whether LwM2M bootstrap server update is enabled") private boolean bootstrapServerUpdateEnable; + @ArraySchema(schema = @Schema(implementation = LwM2MBootstrapServerCredential.class)) private List bootstrap; + @Schema(description = "Other LwM2M client settings") private OtherConfiguration clientLwM2mSettings; public Lwm2mDeviceProfileTransportConfiguration() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java index 4e165e7745..a5df705800 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java @@ -15,6 +15,8 @@ */ 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 lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.StringUtils; @@ -23,19 +25,27 @@ import org.thingsboard.server.common.data.validation.NoXss; import java.util.Objects; import java.util.Set; +@Schema @Data public class MqttDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { + @Schema @NoXss private String deviceTelemetryTopic = MqttTopics.DEVICE_TELEMETRY_TOPIC; + @Schema @NoXss private String deviceAttributesTopic = MqttTopics.DEVICE_ATTRIBUTES_TOPIC; + @Schema @NoXss private String deviceAttributesSubscribeTopic = MqttTopics.DEVICE_ATTRIBUTES_TOPIC; + @Schema private TransportPayloadTypeConfiguration transportPayloadTypeConfiguration; + @Schema private boolean sparkplug; + @ArraySchema(schema = @Schema(implementation = String.class)) private Set sparkplugAttributesMetricNames; + @Schema private boolean sendAckOnValidationException; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java index c13f0b2d09..a1e55f3452 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java @@ -18,12 +18,14 @@ package org.thingsboard.server.common.data.device.profile; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; import com.squareup.wire.schema.Location; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.DynamicProtoUtils; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TransportPayloadType; +@Schema @Slf4j @Data public class ProtoTransportPayloadConfiguration implements TransportPayloadTypeConfiguration { @@ -43,6 +45,9 @@ public class ProtoTransportPayloadConfiguration implements TransportPayloadTypeC private boolean enableCompatibilityWithJsonPayloadFormat; private boolean useJsonPayloadFormatForDefaultDownlinkTopics; + @Schema( + description = "Transport payload type", requiredMode = Schema.RequiredMode.REQUIRED + ) @Override public TransportPayloadType getTransportPayloadType() { return TransportPayloadType.PROTOBUF; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java index 323b29a327..1685b5da8e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java @@ -16,17 +16,21 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.query.FilterPredicateValue; +@Schema @Data @JsonIgnoreProperties(ignoreUnknown = true) @Deprecated public class RepeatingAlarmConditionSpec implements AlarmConditionSpec { + @Schema(description = "Repeating predicate") private FilterPredicateValue predicate; @Override + @Schema(description = "Type of the Alarm Condition Specification", requiredMode = Schema.RequiredMode.REQUIRED) public AlarmConditionSpecType getType() { return AlarmConditionSpecType.REPEATING; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java index 53a87a6b99..caa65777e9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java @@ -16,8 +16,10 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data @JsonIgnoreProperties(ignoreUnknown = true) @Deprecated diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SnmpDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SnmpDeviceProfileTransportConfiguration.java index ee1656c04a..a83920b777 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SnmpDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SnmpDeviceProfileTransportConfiguration.java @@ -16,16 +16,20 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.transport.snmp.config.SnmpCommunicationConfig; import java.util.List; +@Schema @Data public class SnmpDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { private Integer timeoutMs; private Integer retries; + @ArraySchema(schema = @Schema(implementation = SnmpCommunicationConfig.class)) private List communicationConfigs; @Override 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/device/profile/TransportPayloadTypeConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/TransportPayloadTypeConfiguration.java index c7111f8ffd..38176bd740 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/TransportPayloadTypeConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/TransportPayloadTypeConfiguration.java @@ -19,10 +19,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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.TransportPayloadType; import java.io.Serializable; +@Schema( + description = "Configuration for transport payload type", + discriminatorProperty = "transportPayloadType", + discriminatorMapping = { + @DiscriminatorMapping(value = "JSON", schema = JsonTransportPayloadConfiguration.class), + @DiscriminatorMapping(value = "PROTOBUF", schema = ProtoTransportPayloadConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java index ad0cdaa6bf..ab95dae195 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.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 lombok.NoArgsConstructor; import org.thingsboard.server.common.data.DeviceProfileProvisionType; +@Schema @Data @NoArgsConstructor public class X509CertificateChainProvisionConfiguration implements DeviceProfileProvisionConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/ObjectAttributes.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/ObjectAttributes.java index 393e08efee..6e83e82692 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/ObjectAttributes.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/ObjectAttributes.java @@ -16,11 +16,13 @@ package org.thingsboard.server.common.data.device.profile.lwm2m; import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.eclipse.leshan.core.LwM2m; import java.io.Serializable; +@Schema @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ObjectAttributes implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java index 0b0d4095b1..cdf9ed89df 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.device.profile.lwm2m; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -23,6 +24,7 @@ import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.device.data.PowerMode; import org.thingsboard.server.common.data.device.data.PowerSavingConfiguration; +@Schema @EqualsAndHashCode(callSuper = true) @Data @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java index 7362ac3ac3..6513e2fe55 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.device.profile.lwm2m; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -27,16 +28,23 @@ import java.util.Set; @Data @NoArgsConstructor +@Schema public class TelemetryMappingConfiguration implements Serializable { private static final long serialVersionUID = -7594999741305410419L; + @Schema(description = "Map of LwM2M resource paths to telemetry key names") private Map keyName; + @Schema(description = "Set of resources to observe") private Set observe; + @Schema(description = "Set of attribute keys") private Set attribute; + @Schema(description = "Set of telemetry keys") private Set telemetry; + @Schema(description = "Map of resource paths to specific LwM2M object attributes") private Map attributeLwm2m; private Boolean initAttrTelAsObsStrategy; + @Schema(description = "Observation strategy for telemetry") private TelemetryObserveStrategy observeStrategy; @JsonCreator diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java index d50882f699..6866271aec 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.edge; +import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -153,4 +154,13 @@ public class Edge extends BaseDataWithAdditionalInfo implements HasLabel return this.secret; } + @Schema(description = "Additional parameters of the edge. " + + "May include: 'description' (string).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Edge at location A\"}") + @Override + public JsonNode getAdditionalInfo() { + return super.getAdditionalInfo(); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java index 834e19a1c2..474a0270c1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java @@ -48,7 +48,8 @@ public enum EdgeEventType { OAUTH2_CLIENT(true, EntityType.OAUTH2_CLIENT), DOMAIN(true, EntityType.DOMAIN), CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD), - AI_MODEL(true, EntityType.AI_MODEL); + AI_MODEL(true, EntityType.AI_MODEL), + API_KEY(true, EntityType.API_KEY); private final boolean allEdgesRelated; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java index 8779416106..01463dade8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.event; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.StringUtils; @@ -26,10 +27,17 @@ public abstract class DebugEventFilter implements EventFilter { @Schema(description = "String value representing the server name, identifier or ip address where the platform is running", example = "ip-172-31-24-152") protected String server; @Schema(description = "Boolean value to filter the errors", allowableValues = {"false", "true"}) + @JsonProperty("isError") protected boolean isError; @Schema(description = "The case insensitive 'contains' filter based on error message", example = "not present in the DB") protected String errorStr; + @JsonProperty("isError") + public boolean isError() { + return isError; + } + + @JsonProperty("isError") public void setIsError(boolean isError) { this.isError = isError; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java index 07789b9208..4d955f1817 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java @@ -17,9 +17,21 @@ package org.thingsboard.server.common.data.event; 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; -@Schema +@Schema( + description = "Filter for various event types", + discriminatorProperty = "eventType", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEBUG_RULE_NODE", schema = RuleNodeDebugEventFilter.class), + @DiscriminatorMapping(value = "DEBUG_RULE_CHAIN", schema = RuleChainDebugEventFilter.class), + @DiscriminatorMapping(value = "ERROR", schema = ErrorEventFilter.class), + @DiscriminatorMapping(value = "LC_EVENT", schema = LifeCycleEventFilter.class), + @DiscriminatorMapping(value = "STATS", schema = StatisticsEventFilter.class), + @DiscriminatorMapping(value = "DEBUG_CALCULATED_FIELD", schema = CalculatedFieldDebugEventFilter.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java index da7ade45e1..5923b8c02c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class AdminSettingsId extends UUIDBased implements EntityId { @Serial @@ -33,7 +34,7 @@ public class AdminSettingsId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ADMIN_SETTINGS", allowableValues = "ADMIN_SETTINGS") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ADMIN_SETTINGS", allowableValues = "ADMIN_SETTINGS") @Override public EntityType getEntityType() { return EntityType.ADMIN_SETTINGS; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java index dbb8b25635..70476564d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class AiModelId extends UUIDBased implements EntityId { @Serial @@ -36,6 +37,7 @@ public final class AiModelId extends UUIDBased implements EntityId { @Override @Schema( requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_ONLY, description = "Entity type of the AI model", example = "AI_MODEL", allowableValues = "AI_MODEL" diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java index 444492c328..db2c63d614 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class AlarmId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -36,7 +36,7 @@ public class AlarmId extends UUIDBased implements EntityId { return new AlarmId(UUID.fromString(alarmId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ALARM", allowableValues = "ALARM") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ALARM", allowableValues = "ALARM") @Override public EntityType getEntityType() { return EntityType.ALARM; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java index 514c29665c..186dcaca40 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class ApiKeyId extends UUIDBased implements EntityId { @Serial @@ -38,7 +39,7 @@ public class ApiKeyId extends UUIDBased implements EntityId { } @Override - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "API_KEY", allowableValues = "API_KEY") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "API_KEY", allowableValues = "API_KEY") public EntityType getEntityType() { return EntityType.API_KEY; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java index 78e5c90bee..02d6d387a2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class ApiUsageStateId extends UUIDBased implements EntityId { @JsonCreator @@ -34,7 +34,7 @@ public class ApiUsageStateId extends UUIDBased implements EntityId { return new ApiUsageStateId(UUID.fromString(userId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "API_USAGE_STATE", allowableValues = "API_USAGE_STATE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "API_USAGE_STATE", allowableValues = "API_USAGE_STATE") @Override public EntityType getEntityType() { return EntityType.API_USAGE_STATE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java index dc42fffeb5..2d57cc365d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class AssetId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -36,7 +36,7 @@ public class AssetId extends UUIDBased implements EntityId { return new AssetId(UUID.fromString(assetId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ASSET", allowableValues = "ASSET") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ASSET", allowableValues = "ASSET") @Override public EntityType getEntityType() { return EntityType.ASSET; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java index f4f7a3de70..981f381a4a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class AssetProfileId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class AssetProfileId extends UUIDBased implements EntityId { return new AssetProfileId(UUID.fromString(assetProfileId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ASSET_PROFILE", allowableValues = "ASSET_PROFILE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ASSET_PROFILE", allowableValues = "ASSET_PROFILE") @Override public EntityType getEntityType() { return EntityType.ASSET_PROFILE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java index 5168515cd8..094df0e935 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class CalculatedFieldId extends UUIDBased implements EntityId { @Serial @@ -38,7 +38,7 @@ public class CalculatedFieldId extends UUIDBased implements EntityId { return new CalculatedFieldId(UUID.fromString(calculatedFieldId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "CALCULATED_FIELD", allowableValues = "CALCULATED_FIELD") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "CALCULATED_FIELD", allowableValues = "CALCULATED_FIELD") @Override public EntityType getEntityType() { return EntityType.CALCULATED_FIELD; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java index a3b1e28147..4de326f26c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public final class CustomerId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -32,7 +32,7 @@ public final class CustomerId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "CUSTOMER", allowableValues = "CUSTOMER") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "CUSTOMER", allowableValues = "CUSTOMER") @Override public EntityType getEntityType() { return EntityType.CUSTOMER; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java index bd326257a6..c98e70177e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class DashboardId extends UUIDBased implements EntityId { @JsonCreator @@ -34,7 +34,7 @@ public class DashboardId extends UUIDBased implements EntityId { return new DashboardId(UUID.fromString(dashboardId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "DASHBOARD", allowableValues = "DASHBOARD") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "DASHBOARD", allowableValues = "DASHBOARD") @Override public EntityType getEntityType() { return EntityType.DASHBOARD; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java index 7dd840815b..a7b9c12e19 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class DeviceId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -37,7 +37,7 @@ public class DeviceId extends UUIDBased implements EntityId { } @Override - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "DEVICE", allowableValues = "DEVICE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "DEVICE", allowableValues = "DEVICE") public EntityType getEntityType() { return EntityType.DEVICE; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java index 2adba3640c..dea0dd2a65 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class DeviceProfileId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class DeviceProfileId extends UUIDBased implements EntityId { return new DeviceProfileId(UUID.fromString(deviceProfileId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "DEVICE_PROFILE", allowableValues = "DEVICE_PROFILE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "DEVICE_PROFILE", allowableValues = "DEVICE_PROFILE") @Override public EntityType getEntityType() { return EntityType.DEVICE_PROFILE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DomainId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DomainId.java index 0768eec45e..223ea8d8b5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DomainId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DomainId.java @@ -17,10 +17,12 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class DomainId extends UUIDBased implements EntityId { @JsonCreator @@ -32,6 +34,7 @@ public class DomainId extends UUIDBased implements EntityId { return new DomainId(UUID.fromString(oauth2DomainId)); } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "DOMAIN", allowableValues = "DOMAIN") @Override public EntityType getEntityType() { return EntityType.DOMAIN; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java index 05019a608f..45a95d6632 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class EdgeId extends UUIDBased implements EntityId { @Serial @@ -43,7 +44,7 @@ public class EdgeId extends UUIDBased implements EntityId { return new EdgeId(UUID.fromString(edgeId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "EDGE", allowableValues = "EDGE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "EDGE", allowableValues = "EDGE") @Override public EntityType getEntityType() { return EntityType.EDGE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java index c57dc79514..c96b4c38d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; @@ -26,7 +27,47 @@ import java.util.UUID; @JsonDeserialize(using = EntityIdDeserializer.class) @JsonSerialize(using = EntityIdSerializer.class) -@Schema +@Schema( + discriminatorProperty = "entityType", + discriminatorMapping = { + @DiscriminatorMapping(value = "ADMIN_SETTINGS", schema = AdminSettingsId.class), + @DiscriminatorMapping(value = "AI_MODEL", schema = AiModelId.class), + @DiscriminatorMapping(value = "ALARM", schema = AlarmId.class), + @DiscriminatorMapping(value = "API_KEY", schema = ApiKeyId.class), + @DiscriminatorMapping(value = "API_USAGE_STATE", schema = ApiUsageStateId.class), + @DiscriminatorMapping(value = "ASSET", schema = AssetId.class), + @DiscriminatorMapping(value = "ASSET_PROFILE", schema = AssetProfileId.class), + @DiscriminatorMapping(value = "CALCULATED_FIELD", schema = CalculatedFieldId.class), + @DiscriminatorMapping(value = "CUSTOMER", schema = CustomerId.class), + @DiscriminatorMapping(value = "DASHBOARD", schema = DashboardId.class), + @DiscriminatorMapping(value = "DEVICE", schema = DeviceId.class), + @DiscriminatorMapping(value = "DEVICE_PROFILE", schema = DeviceProfileId.class), + @DiscriminatorMapping(value = "DOMAIN", schema = DomainId.class), + @DiscriminatorMapping(value = "EDGE", schema = EdgeId.class), + @DiscriminatorMapping(value = "ENTITY_VIEW", schema = EntityViewId.class), + @DiscriminatorMapping(value = "JOB", schema = JobId.class), + @DiscriminatorMapping(value = "MOBILE_APP", schema = MobileAppId.class), + @DiscriminatorMapping(value = "MOBILE_APP_BUNDLE", schema = MobileAppBundleId.class), + @DiscriminatorMapping(value = "NOTIFICATION", schema = NotificationId.class), + @DiscriminatorMapping(value = "NOTIFICATION_REQUEST", schema = NotificationRequestId.class), + @DiscriminatorMapping(value = "NOTIFICATION_RULE", schema = NotificationRuleId.class), + @DiscriminatorMapping(value = "NOTIFICATION_TARGET", schema = NotificationTargetId.class), + @DiscriminatorMapping(value = "NOTIFICATION_TEMPLATE", schema = NotificationTemplateId.class), + @DiscriminatorMapping(value = "OAUTH2_CLIENT", schema = OAuth2ClientId.class), + @DiscriminatorMapping(value = "OTA_PACKAGE", schema = OtaPackageId.class), + @DiscriminatorMapping(value = "QUEUE", schema = QueueId.class), + @DiscriminatorMapping(value = "QUEUE_STATS", schema = QueueStatsId.class), + @DiscriminatorMapping(value = "RPC", schema = RpcId.class), + @DiscriminatorMapping(value = "RULE_CHAIN", schema = RuleChainId.class), + @DiscriminatorMapping(value = "RULE_NODE", schema = RuleNodeId.class), + @DiscriminatorMapping(value = "TB_RESOURCE", schema = TbResourceId.class), + @DiscriminatorMapping(value = "TENANT", schema = TenantId.class), + @DiscriminatorMapping(value = "TENANT_PROFILE", schema = TenantProfileId.class), + @DiscriminatorMapping(value = "USER", schema = UserId.class), + @DiscriminatorMapping(value = "WIDGETS_BUNDLE", schema = WidgetsBundleId.class), + @DiscriminatorMapping(value = "WIDGET_TYPE", schema = WidgetTypeId.class) + } +) public interface EntityId extends HasUUID, Serializable { //NOSONAR, the constant is closely related to EntityId UUID NULL_UUID = UUID.fromString("13814000-1dd2-11b2-8080-808080808080"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index 7baa8e72f2..ae033f769c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -114,6 +114,7 @@ public class EntityIdFactory { case DOMAIN -> new DomainId(uuid); case CALCULATED_FIELD -> new CalculatedFieldId(uuid); case AI_MODEL -> new AiModelId(uuid); + case API_KEY -> new ApiKeyId(uuid); case ADMIN_SETTINGS -> new AdminSettingsId(uuid); default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); }; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java index cd5b0164db..8bd357a4fc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java @@ -25,6 +25,7 @@ import java.util.UUID; /** * Created by Victor Basanets on 8/27/2017. */ +@Schema(allOf = EntityId.class) public class EntityViewId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -38,7 +39,7 @@ public class EntityViewId extends UUIDBased implements EntityId { return new EntityViewId(UUID.fromString(entityViewID)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ENTITY_VIEW", allowableValues = "ENTITY_VIEW") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ENTITY_VIEW", allowableValues = "ENTITY_VIEW") @Override public EntityType getEntityType() { return EntityType.ENTITY_VIEW; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java index 51e6b8e539..2d369e8b71 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class JobId extends UUIDBased implements EntityId { @Serial @@ -33,7 +34,7 @@ public class JobId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "JOB", allowableValues = "JOB") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "JOB", allowableValues = "JOB") @Override public EntityType getEntityType() { return EntityType.JOB; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppBundleId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppBundleId.java index 6a9c11207a..cfd0fd109f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppBundleId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppBundleId.java @@ -17,10 +17,12 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class MobileAppBundleId extends UUIDBased implements EntityId{ @JsonCreator @@ -32,6 +34,7 @@ public class MobileAppBundleId extends UUIDBased implements EntityId{ return new MobileAppBundleId(UUID.fromString(mobileAppId)); } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "MOBILE_APP_BUNDLE", allowableValues = "MOBILE_APP_BUNDLE") @Override public EntityType getEntityType() { return EntityType.MOBILE_APP_BUNDLE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppId.java index f19e52cc46..bf3571bf7e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppId.java @@ -17,10 +17,12 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class MobileAppId extends UUIDBased implements EntityId{ @JsonCreator @@ -32,6 +34,7 @@ public class MobileAppId extends UUIDBased implements EntityId{ return new MobileAppId(UUID.fromString(mobileAppId)); } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "MOBILE_APP", allowableValues = "MOBILE_APP") @Override public EntityType getEntityType() { return EntityType.MOBILE_APP; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java index 3ba6bf4ca4..a702648a52 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION", allowableValues = "NOTIFICATION") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION", allowableValues = "NOTIFICATION") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java index 5ff42bc0e2..3caa5f1f8c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationRequestId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationRequestId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_REQUEST", allowableValues = "NOTIFICATION_REQUEST") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION_REQUEST", allowableValues = "NOTIFICATION_REQUEST") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_REQUEST; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java index ff57e4d252..39e2ccf54c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationRuleId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationRuleId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_RULE", allowableValues = "NOTIFICATION_RULE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION_RULE", allowableValues = "NOTIFICATION_RULE") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_RULE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java index d1a1cfd00b..593ce1c1aa 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationTargetId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationTargetId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_TARGET", allowableValues = "NOTIFICATION_TARGET") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION_TARGET", allowableValues = "NOTIFICATION_TARGET") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_TARGET; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java index ae2ea582fd..5a1635044a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationTemplateId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationTemplateId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_TEMPLATE", allowableValues = "NOTIFICATION_TEMPLATE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION_TEMPLATE", allowableValues = "NOTIFICATION_TEMPLATE") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_TEMPLATE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ClientId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ClientId.java index 9ef5871135..491643b921 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ClientId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ClientId.java @@ -17,10 +17,12 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class OAuth2ClientId extends UUIDBased implements EntityId { @JsonCreator @@ -32,6 +34,7 @@ public class OAuth2ClientId extends UUIDBased implements EntityId { return new OAuth2ClientId(UUID.fromString(oauth2ClientId)); } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "OAUTH2_CLIENT", allowableValues = "OAUTH2_CLIENT") @Override public EntityType getEntityType() { return EntityType.OAUTH2_CLIENT; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java index 0261b2973e..f9f508f13f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class OtaPackageId extends UUIDBased implements EntityId { @Serial @@ -37,7 +38,7 @@ public class OtaPackageId extends UUIDBased implements EntityId { return new OtaPackageId(UUID.fromString(firmwareId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "OTA_PACKAGE", allowableValues = "OTA_PACKAGE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "OTA_PACKAGE", allowableValues = "OTA_PACKAGE") @Override public EntityType getEntityType() { return EntityType.OTA_PACKAGE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java index c7ee46da2a..3c00c08bc1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class QueueId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class QueueId extends UUIDBased implements EntityId { return new QueueId(UUID.fromString(queueId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "QUEUE", allowableValues = "QUEUE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "QUEUE", allowableValues = "QUEUE") @Override public EntityType getEntityType() { return EntityType.QUEUE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java index 7a921e8755..7869b7bbfe 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class QueueStatsId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class QueueStatsId extends UUIDBased implements EntityId { return new QueueStatsId(UUID.fromString(queueId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "QUEUE_STATS", allowableValues = "QUEUE_STATS") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "QUEUE_STATS", allowableValues = "QUEUE_STATS") @Override public EntityType getEntityType() { return EntityType.QUEUE_STATS; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java index 559855e194..a03b0138d6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class RpcId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -31,7 +32,7 @@ public final class RpcId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "RPC", allowableValues = "RPC") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "RPC", allowableValues = "RPC") @Override public EntityType getEntityType() { return EntityType.RPC; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java index 0512f518af..c77cec808e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class RuleChainId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class RuleChainId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "RULE_CHAIN", allowableValues = "RULE_CHAIN") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "RULE_CHAIN", allowableValues = "RULE_CHAIN") @Override public EntityType getEntityType() { return EntityType.RULE_CHAIN; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java index 645acfe5e2..6be3371969 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class RuleNodeId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class RuleNodeId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "RULE_NODE", allowableValues = "RULE_NODE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "RULE_NODE", allowableValues = "RULE_NODE") @Override public EntityType getEntityType() { return EntityType.RULE_NODE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java index cf171855b2..b08dcb56b4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class TbResourceId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -31,7 +32,7 @@ public class TbResourceId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "TB_RESOURCE", allowableValues = "TB_RESOURCE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "TB_RESOURCE", allowableValues = "TB_RESOURCE") @Override public EntityType getEntityType() { return EntityType.TB_RESOURCE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java index f219452c07..47be35fb84 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class TenantId extends UUIDBased implements EntityId { @JsonIgnore @@ -54,7 +55,7 @@ public final class TenantId extends UUIDBased implements EntityId { return this.equals(SYS_TENANT_ID); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "TENANT", allowableValues = "TENANT") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "TENANT", allowableValues = "TENANT") @Override public EntityType getEntityType() { return EntityType.TENANT; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java index 5b46b772ba..7ced9faa43 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class TenantProfileId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class TenantProfileId extends UUIDBased implements EntityId { return new TenantProfileId(UUID.fromString(tenantProfileId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "TENANT_PROFILE", allowableValues = "TENANT_PROFILE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "TENANT_PROFILE", allowableValues = "TENANT_PROFILE") @Override public EntityType getEntityType() { return EntityType.TENANT_PROFILE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/UserId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/UserId.java index d4f113bef5..0bb939d958 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/UserId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/UserId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class UserId extends UUIDBased implements EntityId { @JsonCreator @@ -34,7 +34,7 @@ public class UserId extends UUIDBased implements EntityId { return new UserId(UUID.fromString(userId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "USER", allowableValues = "USER") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "USER", allowableValues = "USER") @Override public EntityType getEntityType() { return EntityType.USER; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java index fe8b40a7b9..cd9dcec8b3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class WidgetTypeId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -31,7 +32,7 @@ public final class WidgetTypeId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "WIDGET_TYPE", allowableValues = "WIDGET_TYPE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "WIDGET_TYPE", allowableValues = "WIDGET_TYPE") @Override public EntityType getEntityType() { return EntityType.WIDGET_TYPE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java index f88d81bf74..295ac8c59d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class WidgetsBundleId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -31,7 +32,7 @@ public final class WidgetsBundleId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "WIDGETS_BUNDLE", allowableValues = "WIDGETS_BUNDLE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "WIDGETS_BUNDLE", allowableValues = "WIDGETS_BUNDLE") @Override public EntityType getEntityType() { return EntityType.WIDGETS_BUNDLE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java index 28ebcd2095..4de12e3eeb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.job; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -24,6 +25,7 @@ import lombok.ToString; import java.util.List; +@Schema(description = "Dummy job configuration") @Data @EqualsAndHashCode(callSuper = true) @AllArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java index f68012d793..d99fdf1961 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.job; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -29,6 +30,7 @@ import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.task.TaskResult; import java.util.Set; import java.util.UUID; @@ -82,4 +84,36 @@ public class Job extends BaseData implements HasTenantId { return (C) configuration; } + @JsonIgnore + public String getError() { + if (status == JobStatus.CANCELLED) { + return "The task was cancelled"; + } + if (result.getGeneralError() != null) { + return result.getGeneralError(); + } + if (result.getFailedCount() > 0 && result.getResults() != null) { + StringBuilder errorMessage = new StringBuilder(); + for (TaskResult taskResult : result.getResults()) { + if (taskResult.isSuccess() || taskResult.isDiscarded()) { + continue; + } + String error = taskResult.getError(); + if (error == null) { + continue; + } + if (!errorMessage.isEmpty()) { + if (errorMessage.length() + 2 + error.length() > 256) { + errorMessage.append("..."); + break; + } + errorMessage.append("; "); + } + errorMessage.append(error); + } + return errorMessage.toString(); + } + return "Task failed"; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java index 9480ba11c3..6a10dc7ea1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java @@ -20,6 +20,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Data; import org.thingsboard.server.common.data.job.task.TaskResult; @@ -27,6 +30,12 @@ import org.thingsboard.server.common.data.job.task.TaskResult; import java.io.Serializable; import java.util.List; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DUMMY", schema = DummyJobConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @@ -37,6 +46,7 @@ public abstract class JobConfiguration implements Serializable { @NotBlank private String tasksKey; // internal + @ArraySchema(schema = @Schema(ref = "#/components/schemas/TaskResult")) private List toReprocess; // internal @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java index 2009f10f9f..05c2d51879 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java @@ -20,6 +20,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.job.task.TaskResult; @@ -28,6 +31,13 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; +@Schema( + description = "Job execution result", + discriminatorProperty = "jobType", + discriminatorMapping = { + @DiscriminatorMapping(value = "DUMMY", schema = DummyJobResult.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "jobType") @JsonSubTypes({ @@ -37,15 +47,24 @@ import java.util.List; @NoArgsConstructor public abstract class JobResult implements Serializable { + @Schema(description = "Count of successfully completed tasks") private int successfulCount; + @Schema(description = "Count of failed tasks") private int failedCount; + @Schema(description = "Count of discarded tasks") private int discardedCount; - private Integer totalCount = null; // set when all tasks are submitted + @Schema(description = "Total number of tasks, set when all tasks are submitted", nullable = true) + private Integer totalCount = null; + @ArraySchema(schema = @Schema(ref = "#/components/schemas/TaskResult")) private List results = new ArrayList<>(); + @Schema(description = "General error message if the job failed") private String generalError; + @Schema(description = "Timestamp of the job start, in milliseconds") private long startTs; + @Schema(description = "Timestamp of the job finish, in milliseconds") private long finishTs; + @Schema(description = "Timestamp of the job cancellation, in milliseconds") private long cancellationTs; @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java index e6a4702b81..37f851f611 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java @@ -64,6 +64,11 @@ public class DummyTaskResult extends TaskResult { return JobType.DUMMY; } + @Override + public String getError() { + return failure != null ? failure.getError() : null; + } + @Data @NoArgsConstructor @EqualsAndHashCode(callSuper = true) 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 761b518e98..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,4 +46,6 @@ public abstract class TaskResult { @JsonIgnore public abstract JobType getJobType(); + public abstract String getError(); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/DataType.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/DataType.java index 97f28ba853..925cc73898 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/DataType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/DataType.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.kv; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +@Schema public enum DataType { BOOLEAN(0), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java index f29863d731..877c727381 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java @@ -20,6 +20,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.Views; +@Schema @Data public abstract class AbstractMobilePage implements MobilePage { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/CustomMobilePage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/CustomMobilePage.java index 83f82a571b..156436225f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/CustomMobilePage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/CustomMobilePage.java @@ -24,6 +24,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.Views; +@Schema @Data @Builder @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DashboardPage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DashboardPage.java index 9f6114e4a4..232049adbb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DashboardPage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DashboardPage.java @@ -24,6 +24,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.Views; +@Schema @Data @Builder @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultMobilePage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultMobilePage.java index 710d951edc..20f25afe19 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultMobilePage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultMobilePage.java @@ -24,6 +24,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.Views; +@Schema @Data @Builder @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobileLayoutConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobileLayoutConfig.java index a45fd90838..783d3f68a9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobileLayoutConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobileLayoutConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.mobile.layout; import com.fasterxml.jackson.annotation.JsonView; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.AllArgsConstructor; @@ -28,6 +29,7 @@ import org.thingsboard.server.common.data.Views; import java.util.ArrayList; import java.util.List; +@Schema @Data @Builder @NoArgsConstructor @@ -35,7 +37,7 @@ import java.util.List; @EqualsAndHashCode public class MobileLayoutConfig { - @Schema(description = "List of pages") + @ArraySchema(schema = @Schema(implementation = MobilePage.class)) @JsonView(Views.Public.class) @Valid private List pages = new ArrayList<>(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobilePage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobilePage.java index 61fa5a0c46..4ab8388f8b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobilePage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobilePage.java @@ -19,10 +19,22 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonView; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.Views; import java.io.Serializable; +@Schema( + description = "Configuration for a mobile page", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultMobilePage.class), + @DiscriminatorMapping(value = "DASHBOARD", schema = DashboardPage.class), + @DiscriminatorMapping(value = "WEB_VIEW", schema = WebViewPage.class), + @DiscriminatorMapping(value = "CUSTOM", schema = CustomMobilePage.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -36,6 +48,7 @@ import java.io.Serializable; }) public interface MobilePage extends Serializable { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) @JsonView(Views.Private.class) MobilePageType getType(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/WebViewPage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/WebViewPage.java index c8eafbf58c..89e82ab90e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/WebViewPage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/WebViewPage.java @@ -24,6 +24,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.Views; +@Schema @Data @Builder @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/qrCodeSettings/BadgePosition.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/qrCodeSettings/BadgePosition.java index 72e185327b..20420e3971 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/qrCodeSettings/BadgePosition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/qrCodeSettings/BadgePosition.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.mobile.qrCodeSettings; + public enum BadgePosition { RIGHT, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java index b6a4fec9f8..26b0d10efb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.notification; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; @@ -38,6 +39,7 @@ import org.thingsboard.server.common.data.notification.template.NotificationTemp import java.util.List; import java.util.UUID; +@Schema @Data @EqualsAndHashCode(callSuper = true) @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestInfo.java index 06b02c6183..c54e845a0d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestInfo.java @@ -15,18 +15,23 @@ */ package org.thingsboard.server.common.data.notification; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import java.util.List; +@Schema @Data @NoArgsConstructor @EqualsAndHashCode(callSuper = true) public class NotificationRequestInfo extends NotificationRequest { + @Schema private String templateName; + @ArraySchema(schema = @Schema(implementation = NotificationDeliveryMethod.class)) private List deliveryMethods; public NotificationRequestInfo(NotificationRequest request, String templateName, List deliveryMethods) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java index c102cfcae4..0e742b6fb1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.notification; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.notification.targets.NotificationRecipient; @@ -27,14 +28,19 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; +@Schema(description = "Notification request processing statistics") @Data public class NotificationRequestStats { + @Schema(description = "Number of successfully sent notifications per delivery method", example = "{\"WEB\": 10, \"EMAIL\": 5}") private final Map sent; @JsonIgnore private final AtomicInteger totalSent; + @Schema(description = "Errors per delivery method. Each entry maps recipient name to error message") private final Map> errors; + @Schema(description = "Total number of errors across all delivery methods") private final AtomicInteger totalErrors; + @Schema(description = "General error message if the entire request failed") private String error; @JsonIgnore private final Map> processedRecipients; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java index 9223f53509..bff983f2f4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java @@ -15,17 +15,20 @@ */ package org.thingsboard.server.common.data.notification.rule; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; import java.util.List; import java.util.Map; import java.util.UUID; +@Schema(description = "Default notification rule recipients configuration") @Data -@EqualsAndHashCode(callSuper = true) -public class DefaultNotificationRuleRecipientsConfig extends NotificationRuleRecipientsConfig { +@EqualsAndHashCode +public abstract class DefaultNotificationRuleRecipientsConfig implements NotificationRuleRecipientsConfig { @NotEmpty private List targets; @@ -35,4 +38,114 @@ public class DefaultNotificationRuleRecipientsConfig extends NotificationRuleRec return Map.of(0, targets); } + public static DefaultNotificationRuleRecipientsConfig forTriggerType(NotificationRuleTriggerType triggerType) { + return switch (triggerType) { + case ENTITY_ACTION -> new EntityActionRecipientsConfig(); + case ALARM_COMMENT -> new AlarmCommentRecipientsConfig(); + case ALARM_ASSIGNMENT -> new AlarmAssignmentRecipientsConfig(); + case DEVICE_ACTIVITY -> new DeviceActivityRecipientsConfig(); + case RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT -> new RuleEngineComponentLifecycleEventRecipientsConfig(); + case EDGE_CONNECTION -> new EdgeConnectionRecipientsConfig(); + case EDGE_COMMUNICATION_FAILURE -> new EdgeCommunicationFailureRecipientsConfig(); + case NEW_PLATFORM_VERSION -> new NewPlatformVersionRecipientsConfig(); + case ENTITIES_LIMIT -> new EntitiesLimitRecipientsConfig(); + case API_USAGE_LIMIT -> new ApiUsageLimitRecipientsConfig(); + case RATE_LIMITS -> new RateLimitsRecipientsConfig(); + case TASK_PROCESSING_FAILURE -> new TaskProcessingFailureRecipientsConfig(); + case RESOURCES_SHORTAGE -> new ResourceShortageRecipientsConfig(); + default -> throw new IllegalArgumentException("Unsupported trigger type for default recipients config: " + triggerType); + }; + } + + public static class EntityActionRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ENTITY_ACTION; + } + } + + public static class AlarmCommentRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ALARM_COMMENT; + } + } + + public static class AlarmAssignmentRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ALARM_ASSIGNMENT; + } + } + + public static class DeviceActivityRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.DEVICE_ACTIVITY; + } + } + + public static class RuleEngineComponentLifecycleEventRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT; + } + } + + public static class EdgeConnectionRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.EDGE_CONNECTION; + } + } + + public static class EdgeCommunicationFailureRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.EDGE_COMMUNICATION_FAILURE; + } + } + + public static class NewPlatformVersionRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.NEW_PLATFORM_VERSION; + } + } + + public static class EntitiesLimitRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ENTITIES_LIMIT; + } + } + + public static class ApiUsageLimitRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.API_USAGE_LIMIT; + } + } + + public static class RateLimitsRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.RATE_LIMITS; + } + } + + public static class TaskProcessingFailureRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.TASK_PROCESSING_FAILURE; + } + } + + public static class ResourceShortageRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.RESOURCES_SHORTAGE; + } + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java index 459a15c0fd..fa5a10f983 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java @@ -15,21 +15,29 @@ */ package org.thingsboard.server.common.data.notification.rule; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; import java.util.List; import java.util.Map; import java.util.UUID; +@Schema(description = "Escalated notification rule recipients configuration") @Data -@EqualsAndHashCode(callSuper = true) -public class EscalatedNotificationRuleRecipientsConfig extends NotificationRuleRecipientsConfig { +@EqualsAndHashCode +public class EscalatedNotificationRuleRecipientsConfig implements NotificationRuleRecipientsConfig { @NotEmpty private Map> escalationTable; + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ALARM; + } + @Override public Map> getTargetsTable() { return escalationTable; 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 de4e05a2cb..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 @@ -17,12 +17,11 @@ package org.thingsboard.server.common.data.notification.rule; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import jakarta.validation.constraints.NotNull; -import lombok.Data; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; import java.io.Serializable; @@ -30,19 +29,47 @@ import java.util.List; import java.util.Map; import java.util.UUID; +@Schema( + discriminatorProperty = "triggerType", + discriminatorMapping = { + @DiscriminatorMapping(value = "ENTITY_ACTION", schema = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class), + @DiscriminatorMapping(value = "ALARM", schema = EscalatedNotificationRuleRecipientsConfig.class), + @DiscriminatorMapping(value = "ALARM_COMMENT", schema = DefaultNotificationRuleRecipientsConfig.AlarmCommentRecipientsConfig.class), + @DiscriminatorMapping(value = "ALARM_ASSIGNMENT", schema = DefaultNotificationRuleRecipientsConfig.AlarmAssignmentRecipientsConfig.class), + @DiscriminatorMapping(value = "DEVICE_ACTIVITY", schema = DefaultNotificationRuleRecipientsConfig.DeviceActivityRecipientsConfig.class), + @DiscriminatorMapping(value = "RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT", schema = DefaultNotificationRuleRecipientsConfig.RuleEngineComponentLifecycleEventRecipientsConfig.class), + @DiscriminatorMapping(value = "EDGE_CONNECTION", schema = DefaultNotificationRuleRecipientsConfig.EdgeConnectionRecipientsConfig.class), + @DiscriminatorMapping(value = "EDGE_COMMUNICATION_FAILURE", schema = DefaultNotificationRuleRecipientsConfig.EdgeCommunicationFailureRecipientsConfig.class), + @DiscriminatorMapping(value = "NEW_PLATFORM_VERSION", schema = DefaultNotificationRuleRecipientsConfig.NewPlatformVersionRecipientsConfig.class), + @DiscriminatorMapping(value = "ENTITIES_LIMIT", schema = DefaultNotificationRuleRecipientsConfig.EntitiesLimitRecipientsConfig.class), + @DiscriminatorMapping(value = "API_USAGE_LIMIT", schema = DefaultNotificationRuleRecipientsConfig.ApiUsageLimitRecipientsConfig.class), + @DiscriminatorMapping(value = "RATE_LIMITS", schema = DefaultNotificationRuleRecipientsConfig.RateLimitsRecipientsConfig.class), + @DiscriminatorMapping(value = "TASK_PROCESSING_FAILURE", schema = DefaultNotificationRuleRecipientsConfig.TaskProcessingFailureRecipientsConfig.class), + @DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class) + }) @JsonIgnoreProperties(ignoreUnknown = true) -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", visible = true, include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class) +@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), + @Type(name = "ALARM_COMMENT", value = DefaultNotificationRuleRecipientsConfig.AlarmCommentRecipientsConfig.class), + @Type(name = "ALARM_ASSIGNMENT", value = DefaultNotificationRuleRecipientsConfig.AlarmAssignmentRecipientsConfig.class), + @Type(name = "DEVICE_ACTIVITY", value = DefaultNotificationRuleRecipientsConfig.DeviceActivityRecipientsConfig.class), + @Type(name = "RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT", value = DefaultNotificationRuleRecipientsConfig.RuleEngineComponentLifecycleEventRecipientsConfig.class), + @Type(name = "EDGE_CONNECTION", value = DefaultNotificationRuleRecipientsConfig.EdgeConnectionRecipientsConfig.class), + @Type(name = "EDGE_COMMUNICATION_FAILURE", value = DefaultNotificationRuleRecipientsConfig.EdgeCommunicationFailureRecipientsConfig.class), + @Type(name = "NEW_PLATFORM_VERSION", value = DefaultNotificationRuleRecipientsConfig.NewPlatformVersionRecipientsConfig.class), + @Type(name = "ENTITIES_LIMIT", value = DefaultNotificationRuleRecipientsConfig.EntitiesLimitRecipientsConfig.class), + @Type(name = "API_USAGE_LIMIT", value = DefaultNotificationRuleRecipientsConfig.ApiUsageLimitRecipientsConfig.class), + @Type(name = "RATE_LIMITS", value = DefaultNotificationRuleRecipientsConfig.RateLimitsRecipientsConfig.class), + @Type(name = "TASK_PROCESSING_FAILURE", value = DefaultNotificationRuleRecipientsConfig.TaskProcessingFailureRecipientsConfig.class), + @Type(name = "RESOURCES_SHORTAGE", value = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class) }) -@Data -public abstract class NotificationRuleRecipientsConfig implements Serializable { +public interface NotificationRuleRecipientsConfig extends Serializable { - @NotNull - @JsonProperty("triggerType") - private NotificationRuleTriggerType triggerType; + NotificationRuleTriggerType getTriggerType(); @JsonIgnore - public abstract Map> getTargetsTable(); + Map> getTargetsTable(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java index fd6b089a42..7511fd7f2a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; import org.thingsboard.server.common.data.id.EntityId; @@ -66,6 +67,7 @@ public class ResourcesShortageTrigger implements NotificationRuleTrigger { return NotificationRuleTriggerType.RESOURCES_SHORTAGE; } + @Schema public enum Resource { CPU, RAM, STORAGE } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java index e6deee9838..0cd2e441ce 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; @@ -30,15 +32,20 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class AlarmAssignmentNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Serial private static final long serialVersionUID = -5313556049809972096L; + @ArraySchema(schema = @Schema(implementation = String.class)) private Set alarmTypes; + @ArraySchema(schema = @Schema(implementation = AlarmSeverity.class)) private Set alarmSeverities; + @ArraySchema(schema = @Schema(implementation = AlarmSearchStatus.class)) private Set alarmStatuses; @NotEmpty + @ArraySchema(schema = @Schema(implementation = Action.class)) private Set notifyOn; @Override @@ -46,6 +53,7 @@ public class AlarmAssignmentNotificationRuleTriggerConfig implements Notificatio return NotificationRuleTriggerType.ALARM_ASSIGNMENT; } + @Schema public enum Action { ASSIGNED, UNASSIGNED } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java index 3aa3e27638..c651077bed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -29,15 +31,21 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class AlarmCommentNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Serial private static final long serialVersionUID = -9164282098882339645L; + @ArraySchema(schema = @Schema(implementation = String.class)) private Set alarmTypes; + @ArraySchema(schema = @Schema(implementation = AlarmSeverity.class)) private Set alarmSeverities; + @ArraySchema(schema = @Schema(implementation = AlarmSearchStatus.class)) private Set alarmStatuses; + @Schema private boolean onlyUserComments; + @Schema private boolean notifyOnCommentUpdate; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java index 20d484efa7..f2be38d6f9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; @@ -31,16 +33,20 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class AlarmNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Serial private static final long serialVersionUID = -7382883720381542344L; + @ArraySchema(schema = @Schema(implementation = String.class)) private Set alarmTypes; + @ArraySchema(schema = @Schema(implementation = AlarmSeverity.class)) private Set alarmSeverities; @NotEmpty + @ArraySchema(schema = @Schema(implementation = AlarmAction.class)) private Set notifyOn; - + @Schema private ClearRule clearRule; @Override @@ -55,6 +61,7 @@ public class AlarmNotificationRuleTriggerConfig implements NotificationRuleTrigg private Set alarmStatuses; } + @Schema public enum AlarmAction { CREATED, SEVERITY_CHANGED, ACKNOWLEDGED, CLEARED } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ApiUsageLimitNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ApiUsageLimitNotificationRuleTriggerConfig.java index e7051778b2..77c75dfce0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ApiUsageLimitNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ApiUsageLimitNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,9 +30,12 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class ApiUsageLimitNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = ApiFeature.class)) private Set apiFeatures; + @ArraySchema(schema = @Schema(implementation = ApiUsageStateValue.class)) private Set notifyOn; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java index 2ad8586f75..568ec50d51 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; @@ -28,11 +30,15 @@ import java.util.UUID; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class DeviceActivityNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set devices; + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set deviceProfiles; // set either devices or profiles @NotEmpty + @ArraySchema(schema = @Schema(implementation = DeviceEvent.class)) private Set notifyOn; @Override @@ -40,6 +46,7 @@ public class DeviceActivityNotificationRuleTriggerConfig implements Notification return NotificationRuleTriggerType.DEVICE_ACTIVITY; } + @Schema public enum DeviceEvent { ACTIVE, INACTIVE } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeCommunicationFailureNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeCommunicationFailureNotificationRuleTriggerConfig.java index 6def4c1799..8d180ae5bf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeCommunicationFailureNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeCommunicationFailureNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -27,8 +29,10 @@ import java.util.UUID; @NoArgsConstructor @AllArgsConstructor @Builder +@Schema public class EdgeCommunicationFailureNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set edges; // if empty - all edges @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeConnectionNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeConnectionNotificationRuleTriggerConfig.java index 264fa9aa55..3caa45f32a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeConnectionNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeConnectionNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -27,9 +29,12 @@ import java.util.UUID; @NoArgsConstructor @AllArgsConstructor @Builder +@Schema public class EdgeConnectionNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set edges; // if empty - all edges + @ArraySchema(schema = @Schema(implementation = EdgeConnectivityEvent.class)) private Set notifyOn; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java index 7c8997dae5..9b51687a6c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import lombok.AllArgsConstructor; import lombok.Builder; @@ -28,8 +30,10 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class EntitiesLimitNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = EntityType.class)) private Set entityTypes; @Max(1) private float threshold; // in percents, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntityActionNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntityActionNotificationRuleTriggerConfig.java index bdc871ad96..d838d899f3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntityActionNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntityActionNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -27,8 +29,10 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class EntityActionNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = EntityType.class)) private Set entityTypes; // maybe add name filter ? private boolean created; private boolean updated; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NewPlatformVersionNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NewPlatformVersionNotificationRuleTriggerConfig.java index b625efb506..64154b6841 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NewPlatformVersionNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NewPlatformVersionNotificationRuleTriggerConfig.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data +@Schema public class NewPlatformVersionNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java index 6fb4bbd48b..a58b3d5eef 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java @@ -20,9 +20,32 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; +@Schema( + name = "NotificationRuleTriggerConfig", + description = "Configuration for notification rule trigger", + discriminatorProperty = "triggerType", + discriminatorMapping = { + @DiscriminatorMapping(value = "ALARM", schema = AlarmNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "DEVICE_ACTIVITY", schema = DeviceActivityNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "ENTITY_ACTION", schema = EntityActionNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "ALARM_COMMENT", schema = AlarmCommentNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT", schema = RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "ALARM_ASSIGNMENT", schema = AlarmAssignmentNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "NEW_PLATFORM_VERSION", schema = NewPlatformVersionNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "ENTITIES_LIMIT", schema = EntitiesLimitNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "API_USAGE_LIMIT", schema = ApiUsageLimitNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "RATE_LIMITS", schema = RateLimitsNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "EDGE_CONNECTION", schema = EdgeConnectionNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "EDGE_COMMUNICATION_FAILURE", schema = EdgeCommunicationFailureNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "TASK_PROCESSING_FAILURE", schema = TaskProcessingFailureNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = ResourcesShortageNotificationRuleTriggerConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType") @JsonSubTypes({ @@ -43,6 +66,7 @@ import java.io.Serializable; }) public interface NotificationRuleTriggerConfig extends Serializable { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) NotificationRuleTriggerType getTriggerType(); @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RateLimitsNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RateLimitsNotificationRuleTriggerConfig.java index 982e20284c..8f2ae1cdc9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RateLimitsNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RateLimitsNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,8 +30,10 @@ import java.util.stream.Collectors; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class RateLimitsNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = LimitedApi.class)) private Set apis; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java index 77782a73cf..f62ed241ca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import lombok.AllArgsConstructor; import lombok.Builder; @@ -27,6 +28,7 @@ import java.io.Serial; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class ResourcesShortageNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Serial diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java index 4b46c26d65..7e987f1d0b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,14 +30,17 @@ import java.util.UUID; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set ruleChains; // if empty - all rule chains - + @ArraySchema(schema = @Schema(implementation = ComponentLifecycleEvent.class)) private Set ruleChainEvents; // available options: STARTED, UPDATED, STOPPED. if empty - all events private boolean onlyRuleChainLifecycleFailures; private boolean trackRuleNodeEvents; + @ArraySchema(schema = @Schema(implementation = ComponentLifecycleEvent.class)) private Set ruleNodeEvents; // available options: STARTED, UPDATED, STOPPED. if empty - all events private boolean onlyRuleNodeLifecycleFailures; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/TaskProcessingFailureNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/TaskProcessingFailureNotificationRuleTriggerConfig.java index 6a88000124..59fd219705 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/TaskProcessingFailureNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/TaskProcessingFailureNotificationRuleTriggerConfig.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; @Data @Builder +@Schema public class TaskProcessingFailureNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java index b5b573a452..2a708ed998 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.notification.settings; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +@Schema @Data public class MobileAppNotificationDeliveryMethodConfig implements NotificationDeliveryMethodConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java index 5eecb63b1e..eb3236ec00 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java @@ -20,10 +20,19 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; 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.notification.NotificationDeliveryMethod; import java.io.Serializable; +@Schema( + discriminatorProperty = "method", + discriminatorMapping = { + @DiscriminatorMapping(value = "SLACK", schema = SlackNotificationDeliveryMethodConfig.class), + @DiscriminatorMapping(value = "MOBILE_APP", schema = MobileAppNotificationDeliveryMethodConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "method") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java index fbfc9f9d95..d5facf4d00 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.settings; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -23,6 +24,7 @@ import org.thingsboard.server.common.data.notification.NotificationDeliveryMetho import java.io.Serializable; import java.util.Map; +@Schema @Data public class NotificationSettings implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java index f2a8a0d6da..283c09bbd8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.notification.settings; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +@Schema @Data public class SlackNotificationDeliveryMethodConfig implements NotificationDeliveryMethodConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java index a241616712..d551aab4c9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.notification.targets; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class MicrosoftTeamsNotificationTargetConfig extends NotificationTargetConfig implements NotificationRecipient { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java index 74a2544d11..932d954d58 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.targets; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -29,6 +30,7 @@ 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 NotificationTarget extends BaseData implements HasTenantId, HasName, ExportableEntity { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java index 98468b9521..2f7f2615bd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java @@ -20,12 +20,22 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "PLATFORM_USERS", schema = PlatformUsersNotificationTargetConfig.class), + @DiscriminatorMapping(value = "SLACK", schema = SlackNotificationTargetConfig.class), + @DiscriminatorMapping(value = "MICROSOFT_TEAMS", schema = MicrosoftTeamsNotificationTargetConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedTenantAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedTenantAdministratorsFilter.java index 7dc10cd6ee..96dda12884 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedTenantAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedTenantAdministratorsFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class AffectedTenantAdministratorsFilter implements UsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedUserFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedUserFilter.java index c1e600d5bb..b2c5348d3b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedUserFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedUserFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class AffectedUserFilter implements UsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java index fbe42938f0..119859db7c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class AllUsersFilter implements SystemLevelUsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java index 34df18244f..bc63801124 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.Data; import java.util.UUID; +@Schema @Data public class CustomerUsersFilter implements UsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/OriginatorEntityOwnerUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/OriginatorEntityOwnerUsersFilter.java index a7dfdff074..375b47cc03 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/OriginatorEntityOwnerUsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/OriginatorEntityOwnerUsersFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class OriginatorEntityOwnerUsersFilter implements UsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java index 1d97552c2e..df26d6ca65 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -22,10 +23,12 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.notification.targets.NotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class PlatformUsersNotificationTargetConfig extends NotificationTargetConfig { + @Schema(implementation = UsersFilter.class) @NotNull @Valid private UsersFilter usersFilter; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java index 8c5330d165..8daf518089 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class SystemAdministratorsFilter implements SystemLevelUsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java index 6f73f45e08..3b551b4fbf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java @@ -15,15 +15,20 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.Set; import java.util.UUID; +@Schema @Data public class TenantAdministratorsFilter implements SystemLevelUsersFilter { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set tenantsIds; + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set tenantProfilesIds; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java index e0adb26d59..2534bc8dbc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java @@ -15,15 +15,19 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import java.util.List; import java.util.UUID; +@Schema @Data public class UserListFilter implements UsersFilter { + @ArraySchema(schema = @Schema(implementation = UUID.class)) @NotEmpty private List usersIds; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UsersFilter.java index 596972be80..2b3628012c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UsersFilter.java @@ -20,7 +20,22 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "USER_LIST", schema = UserListFilter.class), + @DiscriminatorMapping(value = "CUSTOMER_USERS", schema = CustomerUsersFilter.class), + @DiscriminatorMapping(value = "TENANT_ADMINISTRATORS", schema = TenantAdministratorsFilter.class), + @DiscriminatorMapping(value = "AFFECTED_TENANT_ADMINISTRATORS", schema = AffectedTenantAdministratorsFilter.class), + @DiscriminatorMapping(value = "SYSTEM_ADMINISTRATORS", schema = SystemAdministratorsFilter.class), + @DiscriminatorMapping(value = "ALL_USERS", schema = AllUsersFilter.class), + @DiscriminatorMapping(value = "ORIGINATOR_ENTITY_OWNER_USERS", schema = OriginatorEntityOwnerUsersFilter.class), + @DiscriminatorMapping(value = "AFFECTED_USER", schema = AffectedUserFilter.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java index 4febcc1d72..0b8992309e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.notification.targets.slack; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -28,6 +29,7 @@ import org.thingsboard.server.common.data.notification.targets.NotificationRecip import static org.apache.commons.lang3.StringUtils.isEmpty; +@Schema @Data @NoArgsConstructor @AllArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java index 11c84549d2..ba21291465 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.targets.slack; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -22,6 +23,7 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.notification.targets.NotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class SlackNotificationTargetConfig extends NotificationTargetConfig { @@ -31,6 +33,7 @@ public class SlackNotificationTargetConfig extends NotificationTargetConfig { @Valid private SlackConversation conversation; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Type of the notification target") @Override public NotificationTargetType getType() { return NotificationTargetType.SLACK; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java index a679d0e6e7..e8a9784310 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.NoArgsConstructor; @@ -27,6 +29,18 @@ import org.thingsboard.server.common.data.notification.NotificationDeliveryMetho import java.util.List; +@Schema( + description = "Base template for different delivery methods", + discriminatorProperty = "method", + discriminatorMapping = { + @DiscriminatorMapping(value = "WEB", schema = WebDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "EMAIL", schema = EmailDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "SMS", schema = SmsDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "SLACK", schema = SlackDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "MICROSOFT_TEAMS", schema = MicrosoftTeamsDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "MOBILE_APP", schema = MobileAppDeliveryMethodNotificationTemplate.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "method") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java index 59293e9429..261c127ce4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.template; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; @@ -30,6 +31,7 @@ import java.util.List; @NoArgsConstructor @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) +@Schema public class EmailDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { @NoXss(fieldName = "email subject") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MicrosoftTeamsDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MicrosoftTeamsDeliveryMethodNotificationTemplate.java index 97def8310e..4d03afd7e2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MicrosoftTeamsDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MicrosoftTeamsDeliveryMethodNotificationTemplate.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.template; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -30,8 +31,11 @@ import java.util.UUID; @ToString(callSuper = true) public class MicrosoftTeamsDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @Schema private String subject; + @Schema private String themeColor; + @Schema private Button button; private final List templatableValues = List.of( @@ -82,6 +86,7 @@ public class MicrosoftTeamsDeliveryMethodNotificationTemplate extends DeliveryMe this.setEntityIdInState = other.setEntityIdInState; } + @Schema public enum LinkType { LINK, DASHBOARD } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java index 248d2dedf5..c7d8689f45 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.notification.template; import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; @@ -31,8 +32,10 @@ import java.util.List; @ToString(callSuper = true) public class MobileAppDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @Schema(description = "Subject line for the mobile notification", example = "New Message Received") @NotEmpty private String subject; + @Schema(description = "Additional JSON configuration for web buttons/actions", type = "object") private JsonNode additionalConfig; private final List templatableValues = List.of( diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java index 9385c00abf..c0229678f6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; @@ -37,10 +38,12 @@ import java.util.Optional; @ToString(callSuper = true) public class WebDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @Schema(description = "Subject line for the web notification", example = "New Message Received") @NoXss(fieldName = "web notification subject") @Length(fieldName = "web notification subject", max = 150, message = "cannot be longer than 150 chars") @NotEmpty private String subject; + @Schema(description = "Additional JSON configuration for web buttons/actions") private JsonNode additionalConfig; private final List templatableValues = List.of( 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/AlarmCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java index f9d2e8d3d8..8431d7a303 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -31,6 +32,7 @@ import java.util.List; @AllArgsConstructor @Data @ToString +@Schema public class AlarmCountQuery extends EntityCountQuery { private long startTs; private long endTs; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeys.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeys.java index a20d0dca55..532f7f2e1a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeys.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeys.java @@ -37,17 +37,21 @@ public record AvailableEntityKeys( ) Set entityTypes, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "List of unique time series key names available on the matched entities." + ) @ArraySchema( - arraySchema = @Schema(description = "List of unique time series key names available on the matched entities."), schema = @Schema(implementation = String.class, example = "temperature"), uniqueItems = true ) List timeseries, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "List of unique attribute key names available on the matched entities." + ) @ArraySchema( - arraySchema = @Schema(description = "List of unique attribute key names available on the matched entities."), schema = @Schema(implementation = String.class, example = "serialNumber"), uniqueItems = true ) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeysV2.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeysV2.java new file mode 100644 index 0000000000..608177b9ef --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeysV2.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.server.common.data.query; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Schema( + description = """ + Contains unique time series and attribute key names discovered from entities matching a query, + optionally including a sample value for each key.""" +) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AvailableEntityKeysV2( + @Schema( + description = "Set of entity types found among the matched entities.", + example = "[\"DEVICE\", \"ASSET\"]", + requiredMode = Schema.RequiredMode.REQUIRED + ) + Set entityTypes, + + @Schema( + description = """ + List of unique time series keys available on the matched entities, sorted alphabetically. + Omitted when timeseries keys were not requested.""", + nullable = true + ) + @ArraySchema(schema = @Schema(implementation = KeyInfo.class)) + @Nullable List timeseries, + + @Schema( + description = """ + Map of attribute scope to the list of unique attribute keys available on the matched entities. + Only scopes supported by the matched entity types are included. + Omitted when attribute keys were not requested or when none of the requested scopes apply to the matched entity types.""", + nullable = true + ) + @Nullable Map> attributes +) { + + @Schema(description = "Key name with an optional sample value.") + @JsonInclude(JsonInclude.Include.NON_NULL) + public record KeyInfo( + @Schema( + description = "Key name.", + example = "temperature", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String key, + + @Schema( + description = "Most recent sample value for this key across the matched entities. Omitted when samples were not requested.", + nullable = true + ) + @Nullable KeySample sample + ) {} + + @Schema(description = "Most recent value and its timestamp.") + public record KeySample( + @Schema( + description = "Timestamp in milliseconds since epoch.", example = "1707000000000", + requiredMode = Schema.RequiredMode.REQUIRED + ) + long ts, + + @Schema( + description = "Sample value.", + example = "23.5", + requiredMode = Schema.RequiredMode.REQUIRED, + implementation = Object.class + ) + JsonNode value + ) {} + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/BooleanFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/BooleanFilterPredicate.java index 74c4f87c25..c469a7ef75 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/BooleanFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/BooleanFilterPredicate.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class BooleanFilterPredicate implements SimpleKeyFilterPredicate { @@ -28,6 +30,7 @@ public class BooleanFilterPredicate implements SimpleKeyFilterPredicate return FilterPredicateType.BOOLEAN; } + @Schema public enum BooleanOperation { EQUAL, NOT_EQUAL diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java index 4fb663c4c1..557f08eb2b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java @@ -15,14 +15,18 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; @Data +@Schema public class ComplexFilterPredicate implements KeyFilterPredicate { private ComplexOperation operation; + @ArraySchema(schema = @Schema(ref = "#/components/schemas/KeyFilterPredicate")) private List predicates; @Override @@ -30,6 +34,7 @@ public class ComplexFilterPredicate implements KeyFilterPredicate { return FilterPredicateType.COMPLEX; } + @Schema public enum ComplexOperation { AND, OR diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java index 97fe9972ba..5451e557e7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.RequiredArgsConstructor; import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serializable; +@Schema @Data @RequiredArgsConstructor public class DynamicValue implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataSortOrder.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataSortOrder.java index dae84fa598..c7b933add5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataSortOrder.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataSortOrder.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class EntityDataSortOrder { @@ -34,6 +36,7 @@ public class EntityDataSortOrder { this.direction = direction; } + @Schema public enum Direction { ASC, DESC } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java index 4263a4aeea..bf75f7e9ca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java @@ -19,12 +19,34 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import static org.thingsboard.server.common.data.query.AliasEntityId.resolveAliasEntityId; +@Schema( + description = "Filter for selecting entities", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "singleEntity", schema = SingleEntityFilter.class), + @DiscriminatorMapping(value = "entityList", schema = EntityListFilter.class), + @DiscriminatorMapping(value = "entityName", schema = EntityNameFilter.class), + @DiscriminatorMapping(value = "entityType", schema = EntityTypeFilter.class), + @DiscriminatorMapping(value = "assetType", schema = AssetTypeFilter.class), + @DiscriminatorMapping(value = "deviceType", schema = DeviceTypeFilter.class), + @DiscriminatorMapping(value = "edgeType", schema = EdgeTypeFilter.class), + @DiscriminatorMapping(value = "entityViewType", schema = EntityViewTypeFilter.class), + @DiscriminatorMapping(value = "apiUsageState", schema = ApiUsageStateFilter.class), + @DiscriminatorMapping(value = "relationsQuery", schema = RelationsQueryFilter.class), + @DiscriminatorMapping(value = "assetSearchQuery", schema = AssetSearchQueryFilter.class), + @DiscriminatorMapping(value = "deviceSearchQuery", schema = DeviceSearchQueryFilter.class), + @DiscriminatorMapping(value = "entityViewSearchQuery", schema = EntityViewSearchQueryFilter.class), + @DiscriminatorMapping(value = "edgeSearchQuery", schema = EdgeSearchQueryFilter.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java index 8501ddca12..3103a1a161 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java index 6a080e3b28..cb844c5c44 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.query; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.Data; import lombok.Getter; @@ -25,6 +26,7 @@ import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serializable; +@Schema @Data public class FilterPredicateValue implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilterPredicate.java index 949ccc9bc5..cb5c7a835f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilterPredicate.java @@ -18,9 +18,21 @@ package org.thingsboard.server.common.data.query; import com.fasterxml.jackson.annotation.JsonIgnore; 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 java.io.Serializable; +@Schema( + description = "Filter predicate for key-based filtering", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "STRING", schema = StringFilterPredicate.class), + @DiscriminatorMapping(value = "NUMERIC", schema = NumericFilterPredicate.class), + @DiscriminatorMapping(value = "BOOLEAN", schema = BooleanFilterPredicate.class), + @DiscriminatorMapping(value = "COMPLEX", schema = ComplexFilterPredicate.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/NumericFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/NumericFilterPredicate.java index 0b19d56ea6..0d7953defc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/NumericFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/NumericFilterPredicate.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class NumericFilterPredicate implements SimpleKeyFilterPredicate { @@ -28,6 +30,7 @@ public class NumericFilterPredicate implements SimpleKeyFilterPredicate return FilterPredicateType.NUMERIC; } + @Schema public enum NumericOperation { EQUAL, NOT_EQUAL, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java index 8c063201f7..027c4a7c5c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java @@ -15,15 +15,17 @@ */ package org.thingsboard.server.common.data.query; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import java.util.List; import java.util.Set; +@Schema @Data public class RelationsQueryFilter implements EntityFilter { @@ -33,6 +35,7 @@ public class RelationsQueryFilter implements EntityFilter { } private AliasEntityId rootEntity; + @JsonProperty("multiRoot") private boolean isMultiRoot; private EntityType multiRootEntitiesType; private Set multiRootEntityIds; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/SimpleKeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/SimpleKeyFilterPredicate.java index 2a06eb57c2..f85c259034 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/SimpleKeyFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/SimpleKeyFilterPredicate.java @@ -15,8 +15,12 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Base interface for simple key filter predicates") public interface SimpleKeyFilterPredicate extends KeyFilterPredicate { + @Schema(description = "The value associated with the filter predicate") FilterPredicateValue getValue(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java index 046f86f414..c0bbd43378 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java @@ -16,7 +16,6 @@ package org.thingsboard.server.common.data.query; import lombok.Data; -import org.thingsboard.server.common.data.id.EntityId; @Data public class SingleEntityFilter implements EntityFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java index 915dc6ef5f..da127a58b3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.Data; @Data +@Schema public class StringFilterPredicate implements SimpleKeyFilterPredicate { private StringOperation operation; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java index 76918cc8d9..fc24327a6d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java @@ -103,7 +103,9 @@ public class EntityRelation implements HasVersion, Serializable, EdqsObject { this.version = entityRelation.getVersion(); } - @Schema(description = "Additional parameters of the relation", implementation = JsonNode.class) + @Schema(description = "Additional parameters of the relation.", + implementation = JsonNode.class, + example = "{\"description\":\"Power supply connection\"}") public JsonNode getAdditionalInfo() { return BaseDataWithAdditionalInfo.getJson(() -> additionalInfo, () -> additionalInfoBytes); } 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/rule/RuleNode.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java index 981d5c2598..ac1e85dbeb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java @@ -105,7 +105,12 @@ public class RuleNode extends BaseDataWithAdditionalInfo implements return super.getCreatedTime(); } - @Schema(description = "Additional parameters of the rule node. Contains 'layoutX' and 'layoutY' properties for visualization.", implementation = JsonNode.class) + @Schema(description = "Additional parameters of the rule node. " + + "May include: 'layoutX' (number, X coordinate for visualization), " + + "'layoutY' (number, Y coordinate for visualization), " + + "'description' (string).", + implementation = JsonNode.class, + example = "{\"layoutX\":320,\"layoutY\":160,\"description\":\"Filter temperature data\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java index 90b777c11c..e26287708b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java @@ -16,6 +16,8 @@ package org.thingsboard.server.common.data.security.model.mfa; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -29,9 +31,11 @@ import java.util.List; import java.util.Optional; @Data +@Schema(description = "Platform Two-Factor Authentication settings") @JsonIgnoreProperties(ignoreUnknown = true) public class PlatformTwoFaSettings { + @ArraySchema(schema = @Schema(implementation = TwoFaProviderConfig.class)) @Valid @NotNull private List providers; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java index b24bd68c52..f613b37220 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; import java.util.LinkedHashMap; @Data +@Schema(description = "Account Two-Factor Authentication Settings") public class AccountTwoFaSettings { private LinkedHashMap configs; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java index 3c673c1e8e..74a804688d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.security.model.mfa.account; import com.fasterxml.jackson.annotation.JsonGetter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; @@ -23,6 +24,7 @@ import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProvi import java.util.Set; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class BackupCodeTwoFaAccountConfig extends TwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java index 5960fa8a34..5e87cf5ce7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class EmailTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java index 574a8e01b3..e5903fecf5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +@Schema @Data @EqualsAndHashCode(callSuper = true) public abstract class OtpBasedTwoFaAccountConfig extends TwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java index 97755f6195..1a9b2b718e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +@Schema @EqualsAndHashCode(callSuper = true) @Data public class SmsTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java index 384ca39e3b..799e381b4f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class TotpTwoFaAccountConfig extends TwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java index d8783178e0..06a5088117 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; @@ -35,6 +37,16 @@ import java.io.Serializable; @Type(name = "EMAIL", value = EmailTwoFaAccountConfig.class), @Type(name = "BACKUP_CODE", value = BackupCodeTwoFaAccountConfig.class) }) +@Schema( + description = "Base configuration for two-factor authentication accounts", + discriminatorProperty = "providerType", + discriminatorMapping = { + @DiscriminatorMapping(value = "TOTP", schema = TotpTwoFaAccountConfig.class), + @DiscriminatorMapping(value = "SMS", schema = SmsTwoFaAccountConfig.class), + @DiscriminatorMapping(value = "EMAIL", schema = EmailTwoFaAccountConfig.class), + @DiscriminatorMapping(value = "BACKUP_CODE", schema = BackupCodeTwoFaAccountConfig.class) + } +) @Data public abstract class TwoFaAccountConfig implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java index 18da5115f2..5346f996fd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; import lombok.Data; @Data +@Schema public class BackupCodeTwoFaProviderConfig implements TwoFaProviderConfig { @Min(value = 1, message = "must be greater than 0") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java index 56527ebf8a..f9b6d2e618 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @Data +@Schema @EqualsAndHashCode(callSuper = true) public class EmailTwoFaProviderConfig extends OtpBasedTwoFaProviderConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java index 35f18d50e0..bd6b8f4332 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; import lombok.Data; @Data +@Schema public abstract class OtpBasedTwoFaProviderConfig implements TwoFaProviderConfig { @Min(value = 1, message = "is required") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java index e848c7b942..dba2372989 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.Data; @@ -22,6 +23,7 @@ import lombok.EqualsAndHashCode; @EqualsAndHashCode(callSuper = true) @Data +@Schema public class SmsTwoFaProviderConfig extends OtpBasedTwoFaProviderConfig { @NotBlank(message = "is required") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java index de91a5b732..64cabcbbbd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Data; @Data +@Schema public class TotpTwoFaProviderConfig implements TwoFaProviderConfig { @NotBlank diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java index 42c3556b08..1bb883b375 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java @@ -20,7 +20,19 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + description = "Two-factor authentication provider configuration", + discriminatorProperty = "providerType", + discriminatorMapping = { + @DiscriminatorMapping(value = "TOTP", schema = TotpTwoFaProviderConfig.class), + @DiscriminatorMapping(value = "SMS", schema = SmsTwoFaProviderConfig.class), + @DiscriminatorMapping(value = "EMAIL", schema = EmailTwoFaProviderConfig.class), + @DiscriminatorMapping(value = "BACKUP_CODE", schema = BackupCodeTwoFaProviderConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java index 02f1db557b..e35f8da2e8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.sms.config; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { @Schema(description = "SMPP version", allowableValues = "3.3, 3.4", requiredMode = Schema.RequiredMode.REQUIRED) @@ -89,7 +90,7 @@ public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { @Schema(description = "Address range", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String addressRange; - @Schema(allowableValues = {"0-10" ,"13-14"}, + @Schema(minimum = "0", maximum = "14", description = "0 - SMSC Default Alphabet (ASCII for short and long code and to GSM for toll-free, used as default)\n" + "1 - IA5 (ASCII for short and long code, Latin 9 for toll-free (ISO-8859-9))\n" + "2 - Octet Unspecified (8-bit binary)\n" + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderConfiguration.java index 5bba9a578b..5b6bceec35 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderConfiguration.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; 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; @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( @@ -30,6 +32,15 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonSubTypes.Type(value = TwilioSmsProviderConfiguration.class, name = "TWILIO"), @JsonSubTypes.Type(value = SmppSmsProviderConfiguration.class, name = "SMPP") }) +@Schema( + description = "Base configuration for SMS providers", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "AWS_SNS", schema = AwsSnsSmsProviderConfiguration.class), + @DiscriminatorMapping(value = "TWILIO", schema = TwilioSmsProviderConfiguration.class), + @DiscriminatorMapping(value = "SMPP", schema = SmppSmsProviderConfiguration.class) + } +) public interface SmsProviderConfiguration { @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java index 883a8e383b..2373f53283 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java @@ -18,17 +18,23 @@ package org.thingsboard.server.common.data.sync.ie; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.security.DeviceCredentials; +@Schema @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @Data public class DeviceExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.DEVICE; } + @JsonProperty(index = 3) @JsonIgnoreProperties({"id", "deviceId", "createdTime", "version"}) private DeviceCredentials credentials; 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 01b07026fa..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 @@ -23,11 +23,25 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.notification.rule.NotificationRule; +import org.thingsboard.server.common.data.notification.targets.NotificationTarget; +import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.sync.JsonTbEntity; @@ -42,11 +56,44 @@ import java.util.Map; @Type(name = "RULE_CHAIN", value = RuleChainExportData.class), @Type(name = "WIDGET_TYPE", value = WidgetTypeExportData.class), @Type(name = "WIDGETS_BUNDLE", value = WidgetsBundleExportData.class), - @Type(name = "OTA_PACKAGE", value = OtaPackageExportData.class) + @Type(name = "OTA_PACKAGE", value = OtaPackageExportData.class), + @Type(name = "CUSTOMER", value = EntityExportData.CustomerExportData.class), + @Type(name = "TB_RESOURCE", value = EntityExportData.TbResourceExportData.class), + @Type(name = "DASHBOARD", value = EntityExportData.DashboardExportData.class), + @Type(name = "ASSET_PROFILE", value = EntityExportData.AssetProfileExportData.class), + @Type(name = "ASSET", value = EntityExportData.AssetExportData.class), + @Type(name = "DEVICE_PROFILE", value = EntityExportData.DeviceProfileExportData.class), + @Type(name = "ENTITY_VIEW", value = EntityExportData.EntityViewExportData.class), + @Type(name = "NOTIFICATION_TEMPLATE", value = EntityExportData.NotificationTemplateExportData.class), + @Type(name = "NOTIFICATION_TARGET", value = EntityExportData.NotificationTargetExportData.class), + @Type(name = "NOTIFICATION_RULE", value = EntityExportData.NotificationRuleExportData.class), + @Type(name = "AI_MODEL", value = EntityExportData.AiModelExportData.class) }) @JsonInclude(JsonInclude.Include.NON_NULL) +@Schema( + description = "Base export container for ThingsBoard entities", + discriminatorProperty = "entityType", + discriminatorMapping = { + @DiscriminatorMapping(value = "CUSTOMER", schema = EntityExportData.CustomerExportData.class), + @DiscriminatorMapping(value = "DEVICE", schema = DeviceExportData.class), + @DiscriminatorMapping(value = "RULE_CHAIN", schema = RuleChainExportData.class), + @DiscriminatorMapping(value = "WIDGET_TYPE", schema = WidgetTypeExportData.class), + @DiscriminatorMapping(value = "WIDGETS_BUNDLE", schema = WidgetsBundleExportData.class), + @DiscriminatorMapping(value = "OTA_PACKAGE", schema = OtaPackageExportData.class), + @DiscriminatorMapping(value = "TB_RESOURCE", schema = EntityExportData.TbResourceExportData.class), + @DiscriminatorMapping(value = "DASHBOARD", schema = EntityExportData.DashboardExportData.class), + @DiscriminatorMapping(value = "ASSET_PROFILE", schema = EntityExportData.AssetProfileExportData.class), + @DiscriminatorMapping(value = "ASSET", schema = EntityExportData.AssetExportData.class), + @DiscriminatorMapping(value = "DEVICE_PROFILE", schema = EntityExportData.DeviceProfileExportData.class), + @DiscriminatorMapping(value = "ENTITY_VIEW", schema = EntityExportData.EntityViewExportData.class), + @DiscriminatorMapping(value = "NOTIFICATION_TEMPLATE", schema = EntityExportData.NotificationTemplateExportData.class), + @DiscriminatorMapping(value = "NOTIFICATION_TARGET", schema = EntityExportData.NotificationTargetExportData.class), + @DiscriminatorMapping(value = "NOTIFICATION_RULE", schema = EntityExportData.NotificationRuleExportData.class), + @DiscriminatorMapping(value = "AI_MODEL", schema = EntityExportData.AiModelExportData.class) + } +) @Data -public class EntityExportData> { +public abstract class EntityExportData> { public static final Comparator relationsComparator = Comparator .comparing(EntityRelation::getFrom, Comparator.comparing(EntityId::getId)) @@ -61,18 +108,45 @@ public class EntityExportData> { @JsonProperty(index = 2) @JsonTbEntity + @Schema(implementation = ExportableEntity.class) private E entity; @JsonProperty(index = 1) - private EntityType entityType; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + public abstract EntityType getEntityType(); @JsonProperty(index = 100) + @ArraySchema(schema = @Schema(implementation = EntityRelation.class)) private List relations; @JsonProperty(index = 101) + @Schema(description = "Map of attributes where key is the scope of attributes and value is the list of attributes for that scope") private Map> attributes; @JsonProperty(index = 102) @JsonIgnoreProperties({"id", "entityId", "createdTime", "version"}) + @ArraySchema(schema = @Schema(implementation = CalculatedField.class)) private List calculatedFields; + public static EntityExportData newInstance(EntityType entityType) { + return switch (entityType) { + case DEVICE -> new DeviceExportData(); + case RULE_CHAIN -> new RuleChainExportData(); + case WIDGET_TYPE -> new WidgetTypeExportData(); + case WIDGETS_BUNDLE -> new WidgetsBundleExportData(); + case OTA_PACKAGE -> new OtaPackageExportData(); + case CUSTOMER -> new CustomerExportData(); + case TB_RESOURCE -> new TbResourceExportData(); + case DASHBOARD -> new DashboardExportData(); + case ASSET_PROFILE -> new AssetProfileExportData(); + case ASSET -> new AssetExportData(); + case DEVICE_PROFILE -> new DeviceProfileExportData(); + case ENTITY_VIEW -> new EntityViewExportData(); + case NOTIFICATION_TEMPLATE -> new NotificationTemplateExportData(); + case NOTIFICATION_TARGET -> new NotificationTargetExportData(); + case NOTIFICATION_RULE -> new NotificationRuleExportData(); + case AI_MODEL -> new AiModelExportData(); + default -> throw new IllegalArgumentException("Unsupported entity type: " + entityType); + }; + } + public EntityExportData sort() { if (relations != null && !relations.isEmpty()) { relations.sort(relationsComparator); @@ -111,4 +185,92 @@ public class EntityExportData> { return calculatedFields != null && !calculatedFields.isEmpty(); } + @Schema + public static class CustomerExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.CUSTOMER; + } + } + + @Schema + public static class TbResourceExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.TB_RESOURCE; + } + } + + @Schema + public static class DashboardExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.DASHBOARD; + } + } + + @Schema + public static class AssetProfileExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.ASSET_PROFILE; + } + } + + @Schema + public static class AssetExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.ASSET; + } + } + + @Schema + public static class DeviceProfileExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.DEVICE_PROFILE; + } + } + + @Schema + public static class EntityViewExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.ENTITY_VIEW; + } + } + + @Schema + public static class NotificationTemplateExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.NOTIFICATION_TEMPLATE; + } + } + + @Schema + public static class NotificationTargetExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.NOTIFICATION_TARGET; + } + } + + @Schema + public static class NotificationRuleExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.NOTIFICATION_RULE; + } + } + + @Schema + public static class AiModelExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java index ebab6164b3..727c026bc3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java @@ -16,12 +16,18 @@ package org.thingsboard.server.common.data.sync.ie; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackage; +@Schema @EqualsAndHashCode(callSuper = true) public class OtaPackageExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.OTA_PACKAGE; } + /* * OtaPackage is not a versioned entity; its 'version' field is part of the domain model (not used for optimistic locking) * We override both methods to ensure 'version' is not ignored during (de)serialization. diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/RuleChainExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/RuleChainExportData.java index c00e6a59a2..7b9ec8e0a5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/RuleChainExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/RuleChainExportData.java @@ -17,17 +17,23 @@ package org.thingsboard.server.common.data.sync.ie; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; +@Schema @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @Data public class RuleChainExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.RULE_CHAIN; } + @JsonProperty(index = 3) @JsonIgnoreProperties({"ruleChainId", "version"}) private RuleChainMetaData metaData; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetTypeExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetTypeExportData.java index 1da9a64eb1..f69b3603db 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetTypeExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetTypeExportData.java @@ -15,12 +15,18 @@ */ package org.thingsboard.server.common.data.sync.ie; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class WidgetTypeExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.WIDGET_TYPE; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetsBundleExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetsBundleExportData.java index 978b44357a..6883fff6d4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetsBundleExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetsBundleExportData.java @@ -16,18 +16,28 @@ package org.thingsboard.server.common.data.sync.ie; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.widget.WidgetsBundle; import java.util.ArrayList; import java.util.List; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class WidgetsBundleExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.WIDGETS_BUNDLE; } + + @Schema(description = "List of widgets in the bundle") + @ArraySchema(schema = @Schema(implementation = JsonNode.class)) @JsonProperty(index = 3) private List widgets; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataDiff.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataDiff.java index 71d715d586..c3ca21cdaf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataDiff.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataDiff.java @@ -15,13 +15,17 @@ */ package org.thingsboard.server.common.data.sync.vc; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import org.thingsboard.server.common.data.sync.ie.EntityExportData; +@Schema @Data @AllArgsConstructor public class EntityDataDiff { + @Schema(implementation = EntityExportData.class) private EntityExportData currentVersion; + @Schema(implementation = EntityExportData.class) private EntityExportData otherVersion; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/ComplexVersionCreateRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/ComplexVersionCreateRequest.java index d9ba1817cf..13fb3f08d6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/ComplexVersionCreateRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/ComplexVersionCreateRequest.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.sync.vc.request.create; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.EntityType; import java.util.Map; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class ComplexVersionCreateRequest extends VersionCreateRequest { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SingleEntityVersionCreateRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SingleEntityVersionCreateRequest.java index 8a1ebb06e0..4798393cb4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SingleEntityVersionCreateRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SingleEntityVersionCreateRequest.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.sync.vc.request.create; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.EntityId; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class SingleEntityVersionCreateRequest extends VersionCreateRequest { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java index fe30c1d6ae..81b4aca9a0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.sync.vc.request.create; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serial; import java.io.Serializable; +@Schema @Data public class VersionCreateConfig implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequest.java index 1939f2e222..a3e3071474 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequest.java @@ -18,8 +18,18 @@ package org.thingsboard.server.common.data.sync.vc.request.create; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema( + description = "Request for creating a version", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SINGLE_ENTITY", schema = SingleEntityVersionCreateRequest.class), + @DiscriminatorMapping(value = "COMPLEX", schema = ComplexVersionCreateRequest.class) + } +) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @Type(name = "SINGLE_ENTITY", value = SingleEntityVersionCreateRequest.class), @@ -31,6 +41,7 @@ public abstract class VersionCreateRequest { private String versionName; private String branch; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Type of the version to create") public abstract VersionCreateRequestType getType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadConfig.java index 7156232eba..dfc783bffb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.sync.vc.request.load; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @Data +@Schema @EqualsAndHashCode(callSuper = true) public class EntityTypeVersionLoadConfig extends VersionLoadConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadRequest.java index 8bb4902f43..c3a1260fb0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadRequest.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.sync.vc.request.load; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.EntityType; import java.util.Map; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class EntityTypeVersionLoadRequest extends VersionLoadRequest { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/SingleEntityVersionLoadRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/SingleEntityVersionLoadRequest.java index 5379fc69e9..46c760a60f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/SingleEntityVersionLoadRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/SingleEntityVersionLoadRequest.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.sync.vc.request.load; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.EntityId; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class SingleEntityVersionLoadRequest extends VersionLoadRequest { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java index d7620d9ea5..7f0e3158d8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.sync.vc.request.load; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data +@Schema public class VersionLoadConfig { private boolean loadRelations; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequest.java index dffa3bdf3d..85b2def3e1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequest.java @@ -17,10 +17,20 @@ package org.thingsboard.server.common.data.sync.vc.request.load; 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 lombok.Data; import static com.fasterxml.jackson.annotation.JsonSubTypes.Type; +@Schema( + description = "Request for loading a version", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SINGLE_ENTITY", schema = SingleEntityVersionLoadRequest.class), + @DiscriminatorMapping(value = "ENTITY_TYPE", schema = EntityTypeVersionLoadRequest.class) + } +) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @Type(name = "SINGLE_ENTITY", value = SingleEntityVersionLoadRequest.class), @@ -31,6 +41,7 @@ public abstract class VersionLoadRequest { private String versionId; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Type of the version to load") public abstract VersionLoadRequestType getType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index a04816365b..297f92cffb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -173,6 +174,7 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura @Schema(example = "10") private long maxArgumentsPerCF = 10; @Schema(example = "10") + @PositiveOrZero private int minAllowedScheduledUpdateIntervalInSecForCF = 10; @Builder.Default @Schema(example = "2") 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 973b4807d3..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 @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.transport.resource; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(hidden = true) public enum ResourceType { LWM2M_MODEL, JKS, PKCS_12 } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/SnmpCommunicationConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/SnmpCommunicationConfig.java index 0fa4d8f053..d38733af24 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/SnmpCommunicationConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/SnmpCommunicationConfig.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; 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.transport.snmp.SnmpCommunicationSpec; import org.thingsboard.server.common.data.transport.snmp.SnmpMapping; import org.thingsboard.server.common.data.transport.snmp.SnmpMethod; @@ -31,6 +33,17 @@ import org.thingsboard.server.common.data.transport.snmp.config.impl.ToDeviceRpc import java.io.Serializable; import java.util.List; +@Schema( + description = "SNMP communication configuration", + discriminatorProperty = "spec", + discriminatorMapping = { + @DiscriminatorMapping(value = "TELEMETRY_QUERYING", schema = TelemetryQueryingSnmpCommunicationConfig.class), + @DiscriminatorMapping(value = "CLIENT_ATTRIBUTES_QUERYING", schema = ClientAttributesQueryingSnmpCommunicationConfig.class), + @DiscriminatorMapping(value = "SHARED_ATTRIBUTES_SETTING", schema = SharedAttributesSettingSnmpCommunicationConfig.class), + @DiscriminatorMapping(value = "TO_DEVICE_RPC_REQUEST", schema = ToDeviceRpcRequestSnmpCommunicationConfig.class), + @DiscriminatorMapping(value = "TO_SERVER_RPC_REQUEST", schema = ToServerRpcRequestSnmpCommunicationConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "spec") @JsonSubTypes({ @@ -42,6 +55,7 @@ import java.util.List; }) public interface SnmpCommunicationConfig extends Serializable { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Specification of the SNMP communication") SnmpCommunicationSpec getSpec(); @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java index 398f4b6929..1cbaa957f0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java @@ -33,7 +33,7 @@ import java.util.List; @Data @EqualsAndHashCode(callSuper = true) -@JsonPropertyOrder({"fqn", "name", "deprecated", "image", "description", "descriptor", "externalId", "resources"}) +@JsonPropertyOrder({"id", "createdTime", "tenantId", "fqn", "name", "deprecated", "scada", "version", "descriptor", "image", "description", "tags", "externalId", "resources"}) public class WidgetTypeDetails extends WidgetType implements HasName, HasTenantId, HasImage, ExportableEntity { @Schema(description = "Relative or external image URL. Replaced with image data URL (Base64) in case of relative URL and 'inlineImages' option enabled.") 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/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java index 23e0f1add3..a2df32b483 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java @@ -47,8 +47,8 @@ public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest { assertThatThrownBy(() -> cfg.validate(minAllowedInterval)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: " + minAllowedInterval); + .hasMessage("Scheduled update interval (1 seconds) is less than " + + "minimum allowed interval in tenant profile: " + minAllowedInterval + " seconds"); } } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java index 2f391435b2..2c68bcd8c5 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java @@ -28,6 +28,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; @@ -103,4 +104,17 @@ public class GeofencingCalculatedFieldConfigurationTest { assertThat(allowedZonesArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); } + @Test + void validateShouldThrowWhenScheduledUpdateEnabledButIntervalNotSet() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setEntityCoordinates(mock(EntityCoordinates.class)); + cfg.setZoneGroups(Map.of("zone", mock(ZoneGroupConfiguration.class))); + cfg.setScheduledUpdateEnabled(true); + cfg.setScheduledUpdateInterval(null); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh interval is required when periodic zone group refresh is enabled."); + } + } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java index d645d40dcc..9d6434a7c2 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java @@ -15,8 +15,18 @@ */ package org.thingsboard.server.common.data.id; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.EntityType; + +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; public class EntityIdTest { @@ -25,4 +35,44 @@ public class EntityIdTest { Assertions.assertEquals("13814000-1dd2-11b2-8080-808080808080", EntityId.NULL_UUID.toString()); } + @Test + public void allEntityIdImplementors_shouldBeInDiscriminatorMapping() { + Schema schemaAnnotation = EntityId.class.getAnnotation(Schema.class); + assertThat(schemaAnnotation).as("EntityId must have @Schema annotation").isNotNull(); + + DiscriminatorMapping[] mappings = schemaAnnotation.discriminatorMapping(); + Map> discriminatorMap = Arrays.stream(mappings) + .collect(Collectors.toMap(DiscriminatorMapping::value, DiscriminatorMapping::schema)); + + UUID testUuid = UUID.randomUUID(); + for (EntityType entityType : EntityType.values()) { + EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, testUuid); + String typeName = entityType.name(); + + assertThat(discriminatorMap) + .as("EntityId @Schema discriminatorMapping is missing entry for EntityType." + typeName) + .containsKey(typeName); + assertThat(discriminatorMap.get(typeName)) + .as("Discriminator mapping for " + typeName + " should point to " + entityId.getClass().getSimpleName()) + .isEqualTo(entityId.getClass()); + } + } + + @Test + public void allEntityIdImplementors_shouldHaveAllOfEntityId() { + UUID testUuid = UUID.randomUUID(); + for (EntityType entityType : EntityType.values()) { + EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, testUuid); + Class idClass = entityId.getClass(); + Schema schemaAnnotation = idClass.getAnnotation(Schema.class); + + assertThat(schemaAnnotation) + .as(idClass.getSimpleName() + " must have @Schema annotation") + .isNotNull(); + assertThat(schemaAnnotation.allOf()) + .as(idClass.getSimpleName() + " @Schema must include allOf = EntityId.class") + .contains(EntityId.class); + } + } + } \ No newline at end of file diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/sync/ie/EntityExportDataTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/sync/ie/EntityExportDataTest.java new file mode 100644 index 0000000000..dd932fa2c5 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/sync/ie/EntityExportDataTest.java @@ -0,0 +1,52 @@ +/** + * 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.sync.ie; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.EntityType; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EntityExportDataTest { + + @Test + public void newInstance_shouldSupportAllJsonSubTypes() { + JsonSubTypes subTypes = EntityExportData.class.getAnnotation(JsonSubTypes.class); + assertThat(subTypes).as("EntityExportData must have @JsonSubTypes annotation").isNotNull(); + + Set jsonSubTypeNames = Arrays.stream(subTypes.value()) + .map(JsonSubTypes.Type::name) + .collect(Collectors.toSet()); + + for (String typeName : jsonSubTypeNames) { + EntityType entityType = EntityType.valueOf(typeName); + EntityExportData instance = EntityExportData.newInstance(entityType); + + assertThat(instance) + .as("newInstance(%s) should not return null", typeName) + .isNotNull(); + assertThat(instance.getEntityType()) + .as("newInstance(%s).getEntityType() should return %s", typeName, entityType) + .isEqualTo(entityType); + } + } + +} diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index 5959153c6b..f74163642d 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index aa1946b889..2bff3e0b18 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 516d35e41a..311c345b7a 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -48,9 +48,11 @@ enum EdgeVersion { V_4_2_1_2 = 14; V_4_2_2 = 4220; V_4_2_2_1 = 4221; + V_4_2_2_2 = 4222; V_4_3_0_1 = 15; V_4_3_1 = 4310; V_4_3_1_1 = 4311; + V_4_3_1_2 = 4312; V_LATEST = 99999; } @@ -147,6 +149,13 @@ message AiModelUpdateMsg{ string entity = 4; } +message ApiKeyUpdateMsg{ + UpdateMsgType msgType = 1; + int64 idMSB = 2; + int64 idLSB = 3; + string entity = 4; +} + message EntityDataProto { int64 entityIdMSB = 1; int64 entityIdLSB = 2; @@ -457,6 +466,7 @@ message UplinkMsg { repeated AiModelUpdateMsg aiModelUpdateMsg = 27; repeated UserUpdateMsg userUpdateMsg = 28; repeated UserCredentialsUpdateMsg userCredentialsUpdateMsg = 29; + repeated ApiKeyUpdateMsg apiKeyUpdateMsg = 30; } message UplinkResponseMsg { @@ -508,4 +518,5 @@ message DownlinkMsg { repeated OAuth2DomainUpdateMsg oAuth2DomainUpdateMsg = 34; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 35; repeated AiModelUpdateMsg aiModelUpdateMsg = 36; + repeated ApiKeyUpdateMsg apiKeyUpdateMsg = 37; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 250cdf1f1f..854ed34c54 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common 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/message/pom.xml b/common/message/pom.xml index ab67690cb8..48a6471014 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common 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/pom.xml b/common/pom.xml index 8a715a34ed..0759821ed4 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index d41cb11ca8..433b84ceff 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common 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 077c281a05..16a508ee43 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common @@ -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/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java index b6a785316b..50e131103b 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java @@ -63,8 +63,10 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { private boolean enabled; @Value("${usage.stats.report.enabled_per_customer:false}") private boolean enabledPerCustomer; - @Value("${usage.stats.report.interval:10}") + @Value("${usage.stats.report.interval:60}") private int interval; + @Value("${usage.stats.report.urgent_interval:10}") + private int urgentInterval; @Value("${usage.stats.report.pack_size:1024}") private int packSize; @@ -83,20 +85,30 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { stats.put(key, new ConcurrentHashMap<>()); } + Random random = new Random(); scheduler.scheduleWithFixedDelay(() -> { try { - reportStats(); + reportStats(false); } catch (Exception e) { log.warn("Failed to report statistics: ", e); } - }, new Random().nextInt(interval), interval, TimeUnit.SECONDS); + }, random.nextInt(interval), interval, TimeUnit.SECONDS); + scheduler.scheduleWithFixedDelay(() -> { + try { + reportStats(true); + } catch (Exception e) { + log.warn("Failed to report urgent statistics: ", e); + } + }, random.nextInt(urgentInterval), urgentInterval, TimeUnit.SECONDS); } } - private void reportStats() { + private void reportStats(boolean urgent) { ConcurrentMap report = new ConcurrentHashMap<>(); for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { + if (key.isUrgent() != urgent) continue; + ConcurrentMap statsForKey = stats.get(key); statsForKey.forEach((reportLevel, statsValue) -> { long value = statsValue.get(); 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/pom.xml b/common/script/pom.xml index 6209ff94ae..278799abee 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index d2938d0d35..4de100d423 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.1 + 4.3.1.2-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index 5821fc7f3d..2455dc3423 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.1 + 4.3.1.2-SNAPSHOT script org.thingsboard.common.script 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, Iterable4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index 9c701a09d0..a44fd7334f 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java index 708263b26a..843f857218 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java @@ -245,7 +245,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } List getEfentoMeasurements(MeasurementsProtos.ProtoMeasurements protoMeasurements, UUID sessionId) { - String serialNumber = CoapEfentoUtils.convertByteArrayToString(protoMeasurements.getSerialNum().toByteArray()); + String serialNumber = CoapEfentoUtils.convertByteArrayToString(protoMeasurements.getSerialNumber().toByteArray()); boolean batteryStatus = protoMeasurements.getBatteryStatus(); int measurementPeriodBase = protoMeasurements.getMeasurementPeriodBase(); int measurementPeriodFactor = protoMeasurements.getMeasurementPeriodFactor(); @@ -258,6 +258,9 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } Map valuesMap = new TreeMap<>(); + // general measurements per message + valuesMap.put(TimeUnit.SECONDS.toMillis(channelsList.get(0).getTimestamp()), CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, nextTransmissionAtMillis, signal)); + for (int channel = 0; channel < channelsList.size(); channel++) { ProtoChannel protoChannel = channelsList.get(channel); List sampleOffsetsList = protoChannel.getSampleOffsetsList(); @@ -271,6 +274,10 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { long measurementPeriodMillis = TimeUnit.SECONDS.toMillis(measurementPeriod); long startTimestampMillis = TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()); + // measurements per channel + JsonObject tsValues = valuesMap.computeIfAbsent(startTimestampMillis, k -> new JsonObject()); + tsValues.addProperty("measurement_interval_" + (channel + 1), measurementPeriod); + for (int i = 0; i < sampleOffsetsList.size(); i++) { int sampleOffset = sampleOffsetsList.get(i); if (isSensorError(sampleOffset)) { @@ -290,22 +297,16 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } long sampleOffsetMillis = TimeUnit.SECONDS.toMillis(sampleOffset); long measurementTimestamp = startTimestampMillis + Math.abs(sampleOffsetMillis); - values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> new JsonObject()); addBinarySample(protoChannel, currentIsOk, values, channel + 1, sessionId); } else { long timestampMillis = startTimestampMillis + i * measurementPeriodMillis; - values = valuesMap.computeIfAbsent(timestampMillis, k -> CoapEfentoUtils.setDefaultMeasurements( - serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + values = valuesMap.computeIfAbsent(timestampMillis, k -> new JsonObject()); addContinuesSample(protoChannel, sampleOffset, values, channel + 1, sessionId); } } } - if (CollectionUtils.isEmpty(valuesMap)) { - throw new IllegalStateException("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); - } - return valuesMap.entrySet().stream() .map(entry -> new EfentoTelemetry(entry.getKey(), entry.getValue())) .collect(Collectors.toList()); @@ -446,7 +447,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } } - private EfentoTelemetry getEfentoDeviceInfo(DeviceInfoProtos.ProtoDeviceInfo protoDeviceInfo) { + EfentoTelemetry getEfentoDeviceInfo(DeviceInfoProtos.ProtoDeviceInfo protoDeviceInfo) { JsonObject values = new JsonObject(); values.addProperty("sw_version", protoDeviceInfo.getSwVersion()); @@ -476,41 +477,86 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { //modem info DeviceInfoProtos.ProtoModem modem = protoDeviceInfo.getModem(); - values.addProperty("modem_types", modem.getType().toString()); - values.addProperty("sc_EARNFCN_offset", modem.getParameters(0)); - values.addProperty("sc_EARFCN", modem.getParameters(1)); - values.addProperty("sc_PCI", modem.getParameters(2)); - values.addProperty("sc_Cell_id", modem.getParameters(3)); - values.addProperty("sc_RSRP", modem.getParameters(4)); - values.addProperty("sc_RSRQ", modem.getParameters(5)); - values.addProperty("sc_RSSI", modem.getParameters(6)); - values.addProperty("sc_SINR", modem.getParameters(7)); - values.addProperty("sc_Band", modem.getParameters(8)); - values.addProperty("sc_TAC", modem.getParameters(9)); - values.addProperty("sc_ECL", modem.getParameters(10)); - values.addProperty("sc_TX_PWR", modem.getParameters(11)); - values.addProperty("op_mode", modem.getParameters(12)); - values.addProperty("nc_EARFCN", modem.getParameters(13)); - values.addProperty("nc_EARNFCN_offset", modem.getParameters(14)); - values.addProperty("nc_PCI", modem.getParameters(15)); - values.addProperty("nc_RSRP", modem.getParameters(16)); - values.addProperty("RLC_UL_BLER", modem.getParameters(17)); - values.addProperty("RLC_DL_BLER", modem.getParameters(18)); - values.addProperty("MAC_UL_BLER", modem.getParameters(19)); - values.addProperty("MAC_DL_BLER", modem.getParameters(20)); - values.addProperty("MAC_UL_TOTAL_BYTES", modem.getParameters(21)); - values.addProperty("MAC_DL_TOTAL_BYTES", modem.getParameters(22)); - values.addProperty("MAC_UL_total_HARQ_Tx", modem.getParameters(23)); - values.addProperty("MAC_DL_total_HARQ_Tx", modem.getParameters(24)); - values.addProperty("MAC_UL_HARQ_re_Tx", modem.getParameters(25)); - values.addProperty("MAC_DL_HARQ_re_Tx", modem.getParameters(26)); - values.addProperty("RLC_UL_tput", modem.getParameters(27)); - values.addProperty("RLC_DL_tput", modem.getParameters(28)); - values.addProperty("MAC_UL_tput", modem.getParameters(29)); - values.addProperty("MAC_DL_tput", modem.getParameters(30)); - values.addProperty("sleep_duration", modem.getParameters(31)); - values.addProperty("rx_time", modem.getParameters(32)); - values.addProperty("tx_time", modem.getParameters(33)); + DeviceInfoProtos.ModemType modemType = modem.getType(); + values.addProperty("modem_types", modemType.toString()); + values.addProperty("sim_card_identification", modem.getSimCardIdentification()); + values.addProperty("firmware_version", modem.getFirmwareVersion().toString()); + values.addProperty("modem_identification", modem.getModemIdentification()); + if (modem.getModemStatisticsCount() >= 4) { + values.addProperty("modem_transmissions_count", modem.getModemStatistics(0)); + values.addProperty("modem_time_since_last_devinfo", modem.getModemStatistics(1)); + values.addProperty("modem_total_psm_time", modem.getModemStatistics(2)); + values.addProperty("modem_total_active_time", modem.getModemStatistics(3)); + } + switch (modemType) { + case MODEM_TYPE_BC660: + values.addProperty("sc_EARFCN", modem.getParameters(0)); + values.addProperty("sc_EARNFCN_offset", modem.getParameters(1)); + values.addProperty("sc_PCI", modem.getParameters(2)); + values.addProperty("sc_Cell_id", modem.getParameters(3)); + values.addProperty("sc_RSRP", modem.getParameters(4)); + values.addProperty("sc_RSRQ", modem.getParameters(5)); + values.addProperty("sc_RSSI", modem.getParameters(6)); + values.addProperty("sc_SINR", modem.getParameters(7)); + values.addProperty("sc_Band", modem.getParameters(8)); + values.addProperty("sc_TAC", modem.getParameters(9)); + values.addProperty("sc_ECL", modem.getParameters(10)); + values.addProperty("sc_TX_PWR", modem.getParameters(11)); + values.addProperty("op_mode", modem.getParameters(12)); + values.addProperty("nc_EARFCN", modem.getParameters(13)); + values.addProperty("nc_PCI", modem.getParameters(14)); + values.addProperty("nc_RSRP", modem.getParameters(15)); + values.addProperty("nc_RSRQ", modem.getParameters(16)); + values.addProperty("sleep_duration", modem.getParameters(17)); + values.addProperty("rx_time", modem.getParameters(18)); + values.addProperty("tx_time", modem.getParameters(19)); + values.addProperty("PLMN_state", modem.getParameters(20)); + values.addProperty("select_PLMN", modem.getParameters(21)); + break; + case MODEM_TYPE_SHARED_MODEM: + values.addProperty("RSRP", modem.getParameters(0)); + values.addProperty("RSRQ", modem.getParameters(1)); + values.addProperty("RSSI", modem.getParameters(2)); + values.addProperty("SINR", modem.getParameters(3)); + break; + default: + // MODEM_TYPE_UNSPECIFIED, MODEM_TYPE_BC66, MODEM_TYPE_BC66NA + values.addProperty("sc_EARNFCN_offset", modem.getParameters(0)); + values.addProperty("sc_EARFCN", modem.getParameters(1)); + values.addProperty("sc_PCI", modem.getParameters(2)); + values.addProperty("sc_Cell_id", modem.getParameters(3)); + values.addProperty("sc_RSRP", modem.getParameters(4)); + values.addProperty("sc_RSRQ", modem.getParameters(5)); + values.addProperty("sc_RSSI", modem.getParameters(6)); + values.addProperty("sc_SINR", modem.getParameters(7)); + values.addProperty("sc_Band", modem.getParameters(8)); + values.addProperty("sc_TAC", modem.getParameters(9)); + values.addProperty("sc_ECL", modem.getParameters(10)); + values.addProperty("sc_TX_PWR", modem.getParameters(11)); + values.addProperty("op_mode", modem.getParameters(12)); + values.addProperty("nc_EARFCN", modem.getParameters(13)); + values.addProperty("nc_EARNFCN_offset", modem.getParameters(14)); + values.addProperty("nc_PCI", modem.getParameters(15)); + values.addProperty("nc_RSRP", modem.getParameters(16)); + values.addProperty("RLC_UL_BLER", modem.getParameters(17)); + values.addProperty("RLC_DL_BLER", modem.getParameters(18)); + values.addProperty("MAC_UL_BLER", modem.getParameters(19)); + values.addProperty("MAC_DL_BLER", modem.getParameters(20)); + values.addProperty("MAC_UL_TOTAL_BYTES", modem.getParameters(21)); + values.addProperty("MAC_DL_TOTAL_BYTES", modem.getParameters(22)); + values.addProperty("MAC_UL_total_HARQ_Tx", modem.getParameters(23)); + values.addProperty("MAC_DL_total_HARQ_Tx", modem.getParameters(24)); + values.addProperty("MAC_UL_HARQ_re_Tx", modem.getParameters(25)); + values.addProperty("MAC_DL_HARQ_re_Tx", modem.getParameters(26)); + values.addProperty("RLC_UL_tput", modem.getParameters(27)); + values.addProperty("RLC_DL_tput", modem.getParameters(28)); + values.addProperty("MAC_UL_tput", modem.getParameters(29)); + values.addProperty("MAC_DL_tput", modem.getParameters(30)); + values.addProperty("sleep_duration", modem.getParameters(31)); + values.addProperty("rx_time", modem.getParameters(32)); + values.addProperty("tx_time", modem.getParameters(33)); + break; + } //Runtime info DeviceInfoProtos.ProtoRuntime runtimeInfo = protoDeviceInfo.getRuntimeInfo(); @@ -521,7 +567,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { values.addProperty("counter_of_non_confirmable_messages_attempts", runtimeInfo.getMessageCounters(1)); values.addProperty("counter_of_succeeded_messages", runtimeInfo.getMessageCounters(2)); values.addProperty("min_battery_mcu_temp", runtimeInfo.getMinBatteryMcuTemperature()); - values.addProperty("min_battery_voltage", runtimeInfo.getMinBatteryVoltage()); + values.addProperty("min_battery_voltage", runtimeInfo.getBatteryVoltage()); values.addProperty("min_mcu_temp", runtimeInfo.getMinMcuTemperature()); values.addProperty("runtime_errors", runtimeInfo.getRuntimeErrorsCount()); values.addProperty("up_time", runtimeInfo.getUpTime()); @@ -529,8 +575,8 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { return new EfentoTelemetry(System.currentTimeMillis(), values); } - private JsonElement getEfentoConfiguration(byte[] bytes) throws InvalidProtocolBufferException { - return parseString(ProtoConverter.dynamicMsgToJson(bytes, ConfigProtos.getDescriptor().getMessageTypes().get(2))); + JsonElement getEfentoConfiguration(byte[] bytes) throws InvalidProtocolBufferException { + return parseString(ProtoConverter.dynamicMsgToJson(bytes, ConfigProtos.getDescriptor().getMessageTypes().get(0))); } private static String getDate(long seconds) { diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java index 015bf07292..1acac0ec75 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java @@ -59,14 +59,12 @@ public class CoapEfentoUtils { return String.format("%s UTC", simpleDateFormat.format(new Date(timestampInMillis))); } - public static JsonObject setDefaultMeasurements(String serialNumber, boolean batteryStatus, long measurementPeriod, long nextTransmissionAtMillis, long signal, long startTimestampMillis) { + public static JsonObject setDefaultMeasurements(String serialNumber, boolean batteryStatus, long nextTransmissionAtMillis, long signal) { JsonObject values = new JsonObject(); values.addProperty("serial", serialNumber); values.addProperty("battery", batteryStatus ? "ok" : "low"); - values.addProperty("measured_at", convertTimestampToUtcString(startTimestampMillis)); values.addProperty("next_transmission_at", convertTimestampToUtcString(nextTransmissionAtMillis)); values.addProperty("signal", signal); - values.addProperty("measurement_interval", measurementPeriod); return values; } diff --git a/common/transport/coap/src/main/proto/efento/proto_config.proto b/common/transport/coap/src/main/proto/efento/proto_config.proto index ae6d7902b3..1052d338e7 100644 --- a/common/transport/coap/src/main/proto/efento/proto_config.proto +++ b/common/transport/coap/src/main/proto/efento/proto_config.proto @@ -15,338 +15,471 @@ */ syntax = "proto3"; -import "efento/proto_measurement_types.proto"; import "efento/proto_rule.proto"; +import "efento/proto_config_types.proto"; +import "efento/proto_measurement_types.proto"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "ConfigProtos"; -/* Message containing optional channels control parameters */ -message ProtoOutputControlState { - - /* Channel index */ - uint32 channel_index = 1; - - /* Channel state ON/OFF. Range (1 - OFF; 2 - ON) */ - uint32 channel_state = 2; -} - -/* Message containing request data for accessing calibration parameters */ -message ProtoCalibrationParameters { - - /* Request details. Bitmask: */ - /* - calibration_request[0:2] - requested channel number. */ - uint32 calibration_request = 1; - - /* Assignment of a channel. */ - uint32 channel_assignment = 2; - - /* Table of calibration parameters. Max size = 8. */ - repeated int32 parameters = 3; -} - -enum BleAdvertisingPeriodMode { - - /* Invalid value */ - BLE_ADVERTISING_PERIOD_MODE_UNSPECIFIED = 0; +message ProtoConfig { - /* Default behavior - faster advertising when measurement period is < 15s. */ - BLE_ADVERTISING_PERIOD_MODE_DEFAULT = 1; + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ - /* User-configured normal interval is used. */ - BLE_ADVERTISING_PERIOD_MODE_NORMAL = 2; + reserved 1,48; - /* User-configured fast interval is used. */ - BLE_ADVERTISING_PERIOD_MODE_FAST = 3; -} + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ -/* Message containing BLE advertising period configuration */ -message ProtoBleAdvertisingPeriod { + /* Serial number of the device. * + * Length: 6 bytes. * + * This field is only sent by the device. * + * Status: In use [06.00 - LATEST] */ + bytes serial_number = 25; - /* BLE advertising mode: */ - /* - 1: Default, BLE advertising interval is set to 1022.5ms or some lower value, based on continuous measurement period. */ - /* - 2: Normal, uses user-configured value from 'normal' field. */ - /* - 3: Fast, uses user-configured value from 'fast' field (must be lower than or equal to 'normal' field). */ - BleAdvertisingPeriodMode mode = 1; + /* Configuration payload split information: * + * - Values < 0 - Payload split, expect another part of the payload in the next message. * + * The absolute value indicates an index of the current message * + * - Values = 0 - Payload not split * + * - Values > 0 - Last part of the split payload, the value indicates the total number of the messages sent * + * This field is only sent by the device. * + * Status: In use [06.08.00 - LATEST] */ + sint32 payload_split_info = 44; - /* BLE advertising interval when in normal mode, configured in 0.625ms steps. */ - /* Range: [32:16384] */ - uint32 normal = 2; + /* Identifier of the current configuration. * + * The value of this field changes with every configuration change. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as hash [06.00 - 06.xx.xx] */ + uint32 configuration_hash = 21; + + /* Timestamp when the new configuration was set. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as hash_timestamp [06.02 - 06.xx.xx] */ + uint32 configuration_hash_timestamp = 39; + + /* Configuration errors. * + * Up to 20 error codes supported. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as errors [06.00 - 06.xx.xx] */ + repeated uint32 configuration_errors = 20; + + /* Timestamp when a new configuration error was reported. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as timestamp [06.02 - 06.xx.xx] */ + uint32 configuration_error_timestamp = 38; + + /* Measurement channel types. * + * This field is only sent by the device. * + * Status: In use [06.00 - LATEST] */ + repeated MeasurementType channel_types = 27; - /* BLE advertising interval when in fast mode, configured in 0.625ms steps. */ - /* Range: [32:16384] */ - uint32 fast = 3; -} + /* NvM status: * + * - 1 - Defaults restored on CRC error: default sensor configuration restored due to the non-volatile memory configuration * + * data integrity failure * + * - 2 - NvM initialization error: changes in the sensor configuration won't be stored in the non-volatile memory, a power * + * reset of the sensor is required * + * When empty field is sent by the sensor NvM status is OK. * + * NvM status can be cleared by sending to the sensor value 0x7F. * + * Status: In use [07.00.00 - LATEST] */ + uint32 nvm_status = 62; + + /* SERVER STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ + + /* Current time in seconds since 1st of January 1970 (epoch time). * + * This field is only sent by the server. * + * Status: In use [06.00 - LATEST] */ + uint32 current_time = 8; -/* Main message sent in the payload. Each field in this message is independent of the others - only parameters that should be */ -/* changed need to be sent in the payload. */ -/* If the value of a selected parameter shall not be changed, do not include it in the payload */ -message ProtoConfig { + /* REQUEST FIELDS ----------------------------------------------------------------------------------------------------------- */ + + /* Specifies whether the device should accept the configuration without functional testing (e.g., network connection). * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as accept_without_testing [06.00 - 06.xx.xx] */ + bool accept_without_testing_request = 22; + + /* Specifies whether to send the configuration from the sensor to the configuration endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_configuration [06.00 - 06.xx.xx] */ + bool configuration_request = 19; + + /* Specifies whether to send the device information from the sensor to the device information endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_device_info [06.00 - 06.xx.xx] */ + bool device_info_request = 6; + + /* Specifies whether to send the extended configuration from the sensor to the extended configuration endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + bool extended_configuration_request = 63; + + /* Specifies, if software update is available. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_fw_update [06.00 - 06.xx.xx] */ + bool update_software_request = 7; + + /* Device will clear all runtime errors. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_runtime_errors_clear [06.02 - 06.xx.xx] */ + bool clear_runtime_errors_request = 37; + + /* Device will power off its cellular modem for requested number of seconds. * + * Range: [60:604800] (1 minute : 7 days) * + * This field is only sent by the user/server. * + * Status: In use [06.00 - LATEST] */ + uint32 disable_modem_request = 18; - /* DEPRECATED - Used for backward compatibility with fw versions 5.x */ - /* repeated Threshold thresholds = 1; */ - - /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ - /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ - /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ - /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ - /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ - /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ - /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ - /* measurement period base in seconds */ - /* Range [1:65535] - minimum value can vary depends on installed sensors */ + /* Specifies, if the modem firmware update is available. * + * String up to 48 characters: * + * - DFOTA URL - For use by BC66/BC660 modem. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as modem_update_request [06.08.00 - 06.xx.xx] */ + string update_modem_request = 45; + + /* Device will erase all measurements from memory. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as memory_reset_request [06.00 - 06.xx.xx] */ + bool reset_memory_request = 30; + + /* Device will restart the collection of memory statistics. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + bool restart_memory_stats_request = 64; + + /* Specifies whether to send measurements from the sensor starting at the specified timestamp. * + * Tiemstamp in seconds since 1st of January 1970 (epoch time). * + * All previous measurements will be marked as sent. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + uint32 data_transfer_start_timestamp_request = 65; + + /* Device will set new calibration parameters or will send the current set of parameters to the configuration endpoint. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + ProtoCalibrationParametersRequest calibration_parameters_request = 49; + + /* Device will set the new output state of the output control channel pins. * + * Up to 3 channels supported in the one request. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as output_control_state_request [06.13.00/06.21.00 - 06.xx.xx] */ + repeated ProtoOutputControlState set_output_control_state_request = 58; + + /* MEASUREMENTS CONFIGURATION ----------------------------------------------------------------------------------------------- */ + + /* Measurement period defines how often the measurements are to be taken. * + * Sensors of 'Continuous' type take measurement each 'measurement_period_base' * 'measurement_period_factor'. * + * Sensors of 'Binary' type take measurement each 'measurement_period_base'. * + * For backward compatibility with versions 5.xx in case of 'Binary/Mixed' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '14' shall be used for the period calculation. * + * For backward compatibility with versions 5.xx in case of 'Continuous' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '1' shall be used for the period calculation. */ + + /* Measurement period base in seconds. * + * Range: [1:65535] (minimum value may vary depending on sensors installed) * + * Group: Measurement Period * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_base = 2; - /* Measurement period factor */ - /* Range [1:65535] - minimum value can vary depends on installed sensors */ + /* Measurement period factor. * + * Range: [1:65535] (minimum value may vary depending on sensors installed) * + * Group: Measurement Period * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_factor = 26; - /* Transmission interval in seconds. Range: [60:604800] */ - uint32 transmission_interval = 3; + /* BLUETOOTH CONFIGURATION -------------------------------------------------------------------------------------------------- */ - /* BLE turnoff time in seconds. Once receiving this setting, BLE will be switched off after the set number of seconds. */ - /* If BLE is already switched off, it will switch on for the set number of seconds and switch off afterwards. */ - /* Range [60:604800] and 0xFFFFFFFF */ - /* 0xFFFFFFFF - always on */ + /* Bluetooth turn-off time: * + * - [60:604800] - Time in seconds after which Bluetooth is turned off * + * - 0xFFFFFFFF - Bluetooth is always on * + * When this setting is received, Bluetooth is turned off after the set number of seconds. * + * If Bluetooth is already off, it will turn on for the set number of seconds and then turn off. * + * Group: Bluetooth Turn-Off * + * Status: In use [06.00 - LATEST] */ uint32 ble_turnoff_time = 4; - /* ACK interval in seconds */ - /* Range [180:2592000] and 0xFFFFFFFF */ - /* 0xFFFFFFFF - always request ACK */ - uint32 ack_interval = 5; - - /* Specifies, if the additional device info is requested. If true, sensor will send a message to endpoint '/i' with the */ - /* device info. This field is only sent by server */ - bool request_device_info = 6; - - /* Specifies, if software update is available. This field is only sent by server */ - bool request_fw_update = 7; - - /* Current time in seconds sine 1st of January 1970 (epoch time). */ - uint32 current_time = 8; - - /* NB-IoT transfer limit */ - /* Range: [1:65535] */ - /* 65535 - disable transfer limit function */ - uint32 transfer_limit = 9; - - /* NB-IoT transfer limit timer in seconds */ - /* Range: [1:65535] */ - /* 65535 - disable transfer limit function */ - uint32 transfer_limit_timer = 10; - - /* For firmware >= 6.07.00: */ - /* IP or URL address of the data (measurements) server */ - /* The IP or URL of the data server, provided as string with a maximum length of 31 characters */ - /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ - /* For firmware < 6.07.00: */ - /* IP address of the data (measurements) server */ - /* For example, use "18.184.24.239" */ - string data_server_ip = 11; - - /* Data (measurements) server port */ - /* Range: [1:65535] */ - uint32 data_server_port = 12; - - /* For firmware >= 6.07.00: */ - /* IP or URL address of the update server */ - /* The IP or URL of the update server, provided as string with a maximum length of 31 characters */ - /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ - /* For firmware < 6.07.00: */ - /* IP address of the update server */ - /* For example, use "18.184.24.239" */ - string update_server_ip = 13; - - /* Update server port for UDP transmission */ - /* Range: [1:65535] */ - uint32 update_server_port_udp = 14; - - /* Update server port for CoAP transmission */ - /* Range: [1:65535] */ - uint32 update_server_port_coap = 15; - - /* APN as string. Max length 49 */ - /* String with special character 0x7F (DEL) only indicates that automatic apn is turn on */ - string apn = 16; + /* Bluetooth Tx power level. * + * Value is the index of the absolute value of the Tx power, which depends on the BLE module. * + * Range: [1:4] * + * Group: Bluetooth Tx Power * + * Status: In use [06.02 - LATEST] */ + uint32 ble_tx_power_level = 36; - /* PLMN selection */ - /* Range: [100:999999] */ - /* 0xFFFFFFFF or 1000000 - automatic selection */ - uint32 plmn_selection = 17; + /* Encryption key. * + * Max length: 16 bytes. * + * A one-element array containing only one 0x7F (DEL) byte disables encryption. * + * Device sends two last bytes of SHA256 hash of current key in this field. * + * When the encryption key is disabled, the device sends a 0x7F (DEL) byte. * + * Group: Encryption * + * Status: In use [06.11.00 - LATEST] */ + bytes encryption_key = 54; - /* Device will power off its cellular modem for requested number of seconds. */ - /* Range: [60:604800] (1 minute : 7 days) */ - /* This field is only sent by server */ - uint32 disable_modem_request = 18; + /* Bluetooth advertising period. * + * Group: Bluetooth Advertising Period * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + ProtoBleAdvertisingPeriod ble_advertising_period = 59; - /* If set, the device will send its configuration to the endpoint '/c' as a confirmable message */ - /* This field is only sent by server */ - bool request_configuration = 19; + /* Advertisement manufacturer specific data format. * + * Group: Advertisement Manufacturer Data Format * + * Status: In use [07.00.00 - LATEST] */ + AdvertisementManufacturerDataFormat advertisement_manufacturer_data_format = 60; - /* Device's error codes. */ - /* This field is only sent by device */ - repeated uint32 errors = 20; + /* EDGE LOGIC CONFIGURATION ------------------------------------------------------------------------------------------------- */ - /* Identifier of current configuration - Every change of the configuration results in change of the value of this field */ - /* This field is only sent by device */ - uint32 hash = 21; + /* Edge logic rules set on the device. * + * Up to 16 rules supported (previously 12 rules [06.00 - 07.01.xx]). * + * Group: Edge Logic Rule No. # * + * Status: In use [06.00 - LATEST] */ + repeated ProtoRule rules = 28; - /* If true, the device will accept the configuration without functional testing (eg. network connection) */ - bool accept_without_testing = 22; + /* Calendars set on the device. * + * Up to 6 calendars supported. * + * Group: Calendar No. # * + * Status: In use [06.08.00 - LATEST] */ + repeated ProtoCalendar calendars = 47; - /* Cloud token configuration: */ - /* - 1: cloud token set to the value of cloud_token field */ - /* - 2: cloud token set to IMEI of the cellular module */ - /* - 255: do not send cloud_token field */ - uint32 cloud_token_config = 23; + /* SERVER COMMUNICATION CONFIGURATION --------------------------------------------------------------------------------------- */ - /* Cloud token that should be sent with each measurement frame */ - string cloud_token = 24; + /* Transmission interval in seconds. * + * Range: [60:604800] * + * Group: Server Intervals * + * Status: In use [06.00 - LATEST] */ + uint32 transmission_interval = 3; - /* Serial number of the device */ - /* This field is only sent by device */ - bytes serial_number = 25; + /* ACK interval: * + * - [180:2592000] - Time in seconds after which the device will request an ACK * + * - 0xFFFFFFFF - Always request ACK * + * Group: Server Intervals * + * Status: In use [06.00 - LATEST] */ + uint32 ack_interval = 5; - /* Type of channel */ - /* This field is only sent by device */ - repeated MeasurementType channel_types = 27; + /* Server transfer limit: * + * - [1:65534] - Number of transfers * + * - 65535 - Transfer limit disabled * + * Group: Server Transfer Limit * + * Status: In use [06.00 - LATEST] */ + uint32 transfer_limit = 9; - /* Edge logic rules set on the device. Up to 12 rules are supported */ - repeated ProtoRule rules = 28; + /* Server transfer limit timer: * + * - [1:65534] - Time in seconds after which the transfer is renewed * + * - 65535 - Transfer limit disabled * + * Group: Server Transfer Limit * + * Status: In use [06.00 - LATEST] */ + uint32 transfer_limit_timer = 10; - /* Supervision period */ - /* Range: [180:604800] */ - /* 0xFFFFFFFF - Functionality disabled */ + /* Server supervision period: * + * - [180:604800] - Time in seconds after which the device resets itself if there is no communication with the server * + * - 0xFFFFFFFF - Server supervision disabled * + * Group: Server Supervision * + * Status: In use [06.00 - LATEST] */ uint32 supervision_period = 29; - /* If true, sensor's measurement memory will be erased */ - bool memory_reset_request = 30; + /* DATA SERVER CONFIGURATION ------------------------------------------------------------------------------------------------ */ - /* Bytes 0-4 - Band selection mask. Mask = 1 << position */ - /* Band | 1 | 2 | 3 | 4 | 5 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 25 | 26 | 28 | 66 | 71 | 85 | */ - /* Position: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | */ - /* example: To enable band 3, 8 and 20 set to (1 << 2) + (1 << 5) + (1 << 11) = 2084 */ - uint32 modem_bands_mask = 31; + /* Data server address. * + * String up to 31 characters: * + * - IPv4 address (examples: "18.184.24.239") [06.00 - 06.06.xx] * + * - IPv4/URL address (examples: "18.184.24.239", "efento.test.io") [06.07.00 - LATEST] * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + string data_server_ip = 11; + + /* Data server port. * + * Range: [1:65535] * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 data_server_port = 12; - /* Data endpoint (string - max length 16) */ + /* Data endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [06.02 - LATEST] */ string data_endpoint = 32; - /* Configuration endpoint (string - max length 16) */ + /* Configuration endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [06.02 - LATEST] */ string configuration_endpoint = 33; - /* Device info endpoint (string - max length 16) */ + /* Extended configuration endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [07.00.00 - LATEST] */ + string extended_configuration_endpoint = 61; + + /* Device information endpoint. * + * String up to 11 characters. * + * Group: Device Information Endpoint * + * Status: In use [06.02 - LATEST] */ string device_info_endpoint = 34; - /* Time endpoint (string - max length 16) */ + /* Time endpoint. * + * String up to 11 characters. * + * Group: Time Endpoint * + * Status: In use [06.02 - LATEST] */ string time_endpoint = 35; - /* Bluetooth TX power level. Value is the index of the absolute value of TX power, that depends on the BLE module */ - /* Range: [1:4] */ - uint32 ble_tx_power_level = 36; + /* UPDATE SERVER CONFIGURATION ---------------------------------------------------------------------------------------------- */ - /* Deprecated field */ - /* If true, the sensor's runtime errors will be cleared */ - bool request_runtime_errors_clear = 37; + /* Update server address. * + * String up to 31 characters: * + * - IPv4 address (examples: "18.184.24.239") [06.00 - 06.06.xx] * + * - IPv4/URL address (examples: "18.184.24.239", "efento.test.io") [06.07.00 - LATEST] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + string update_server_ip = 13; - /* Timestamp when a new error code was reported */ - uint32 error_timestamp = 38; + /* Update server port for UDP transfer. * + * Range: [1:65535] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + uint32 update_server_port_udp = 14; - /* Timestamp when the new configuration was set */ - uint32 hash_timestamp = 39; + /* Update server port for CoAP transfer. * + * Range: [1:65535] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + uint32 update_server_port_coap = 15; - /* Cloud token CoAP option ID: */ - /* - [1:64999] - CoAP option ID containing cloud token */ - /* - 65000 - cloud token sent in the payload */ - uint32 cloud_token_coap_option = 40; + /* CLOUD CONFIGURATION ------------------------------------------------------------------------------------------------------ */ - /* ECDSA payload signature CoAP option ID: */ - /* - [1:64999] - CoAP option ID containing ECDSA payload signature */ - /* - 65000 - no payload signature in CoAP option */ - uint32 payload_signature_coap_option = 41; + /* Cloud token configuration: * + * - 1 - Cloud token set to the value of the 'cloud_token' field * + * - 2 - Cloud token set to the modem identification (IMEI for cellular modems) * + * - 255 - Do not send 'cloud_token' field * + * Group: Cloud Token * + * Status: In use [06.00 - LATEST] */ + uint32 cloud_token_config = 23; - /* DNS server IP address grouped in the array as four octets. Set 255.255.255.255 to use a network DNS server */ - /* Note: when setting less than four octets the remaining will be filled with zeros. */ - repeated uint32 dns_server_ip = 42; + /* Cloud token that should be sent with each measurement frame. * + * String up to 36 characters. * + * Group: Cloud Token * + * Status: In use [06.00 - LATEST] */ + string cloud_token = 24; - /* DNS TTL configuration: */ - /* - [1:864000] - custom TTL in seconds (additionally, the DNS request when communication has failed) */ - /* - 864001 - accept TTL from the DNS server (additionally, the DNS request when communication has failed) */ - /* - 864002 - DNS request is only after communication failed */ - uint32 dns_ttl_config = 43; + /* Cloud token CoAP option ID: * + * - [1:64999] - CoAP option ID with cloud token * + * - 65000 - Cloud token sent in the payload * + * Group: Cloud Token * + * Status: In use [06.07.00 - LATEST] */ + uint32 cloud_token_coap_option = 40; - /* Configuration payload split information. Information about dividing the payload into parts */ - /* values < 0 - payload has been split, expect another part of the payload in the next message. */ - /* The absolute value indicates an index of the current message. */ - /* value = 0 - payload has not been splitted */ - /* values > 0 - last part of the split payload, the value indicates the total number of the messages sent */ - sint32 payload_split_info = 44; + /* ECDSA payload signature CoAP option ID: * + * - [1:64999] - CoAP option ID with payload signature * + * - 65000 - Payload signature is not sent * + * Group: Cloud Token * + * Status: In use [06.07.00 - LATEST] */ + uint32 payload_signature_coap_option = 41; - /* Modem update request (string - max length 48) */ - /* This field is only sent by server */ - /* For BC66 module, this field is a DFOTA URL */ - string modem_update_request = 45; + /* Modem identification CoAP option ID: * + * - [1:64999] - CoAP option ID with modem identification * + * - 65000 - Modem identification is not sent * + * Group: Cloud Token * + * Status: In use [07.00.00 - LATEST] */ + uint32 modem_identification_coap_option = 52; - /* Cellular configuration parameters. */ - /* 1st item - Number of used cellular parameters */ - /* 2nd - 12th items - Cellular parameters */ - repeated uint32 cellular_config_params = 46; + /* NETWORK CONFIGURATION ---------------------------------------------------------------------------------------------------- */ - /* Calendar configuration. Up to 6 calendars are supported */ - repeated ProtoCalendar calendars = 47; + /* DNS server IP address. * + * Grouped in the array as four octets. Set 255.255.255.255 to use a cellular network DNS server. * + * Note: when setting less than four octets the remaining will be filled with zeros. * + * Group: Data Server * + * Status: In use [06.07.00 - LATEST] */ + repeated uint32 dns_server_ip = 42; - /* DEPRECATED - Used for backward compatibility */ - reserved 48; - - /* Set/get calibration parameters for single channel. */ - ProtoCalibrationParameters calibration_parameters_request = 49; - - /* LED behaviour configuration: */ - /* Period of LEDs flashing (5-600 seconds in 5 seconds resolution): */ - /* - led_config[0] - green LED */ - /* - led_config[1] - red LED */ - /* Time from entering the normal state, after which the LED indication is turned off */ - /* (0-240 minutes in 1 minute resolution, or 255 for always turned on): */ - /* - led_config[2] - flashing red led on communication problem */ - /* - led_config[3] - flashing red led on a sensor problem */ - /* - led_config[4] - flashing red led on a low power */ - /* - led_config[5] - flashing green led on measurement */ - /* - led_config[6] - flashing green led on transmission */ - /* - led_config[7] - flashing green led to indicate sensor's proper operation */ - /* - led_config[8] - Blink duration (20-1000ms in 5 ms resolution) */ - repeated uint32 led_config = 50; + /* DNS TTL configuration: * + * - [1:864000] - Custom TTL in seconds (if communication fails, DNS is also queried) * + * - 864001 - Accept TTL from the DNS server (if communication fails, DNS is also queried) * + * - 864002 - DNS query only after communication failure * + * Group: DNS * + * Status: In use [06.07.00 - LATEST] */ + uint32 dns_ttl_config = 43; - /* Network troubleshooting configuration, if bluetooth is turned off and communication with the server is faulty, */ - /* bluetooth will be automatically turned on until the connection is stabilized */ - /* - 1: network troubleshooting disabled */ - /* - 2: network troubleshooting enabled */ + /* Network troubleshooting: + * - 1 - Network troubleshooting disabled * + * - 2 - Network troubleshooting enabled * + * If Bluetooth is turned off and communication with the server is faulty, Bluetooth will be automatically turned on until * + * the connection is stabilized. * + * Group: Network * + * Status: In use [06.10.00 - LATEST] */ uint32 network_troubleshooting = 51; - /* Reserved by gateway client */ - reserved 52, 53; - - /* Encryption key configuration. Sensor sends in this field two last bytes of SHA256 hash calculated from its current */ - /* encryption_key configuration. When encryption key is disabled one byte 0x7F (DEL) is sent. */ - /* Max length: 16 bytes. */ - /* 0x7F - encryption key disabled. */ - bytes encryption_key = 54; - - /* User name as string. Max length 31 */ - /* String with special character 0x7F (DEL) only indicates that automatic user name is turn on */ - /* User name can only be set to custom value if apn has been configured (is not automatic) */ - string apn_user_name = 55; + /* Network key. * + * Max length: 16 bytes. * + * A one-element array containing only one 0x7F (DEL) byte disables network key. * + * Device sends two last bytes of SHA256 hash of current key in this field. * + * When the network key is disabled, the device sends a 0x7F (DEL) byte. * + * Group: Local Network * + * Status: In use [07.00.00 - LATEST] */ + bytes network_key = 53; + + /* MODEM CONFIGURATION ------------------------------------------------------------------------------------------------------ */ + + /* APN (Access Point Name). * + * String up to 49 characters. * + * A string containing only the special character 0x7F (DEL) indicates that automatic APN is enabled. * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + string apn = 16; - /* Password as string. Max length 31 */ - /* String with special character 0x7F (DEL) only indicates that automatic password is turn on */ - /* Password can only be set to custom value if apn_user_name has been configured (is not automatic) */ + /* APN username. * + * String up to 31 characters. * + * A string containing only the special character 0x7F (DEL) indicates that the username is not being used. * + * The username can only be set to a custom value if APN has been configured. * + * Group: Data Server * + * Status: In use [07.00.00 - LATEST] / Previously as apn_user_name [06.11.00 - 06.xx.xx] */ + string apn_username = 55; + + /* APN password. * + * String up to 31 characters. * + * A string containing only the special character 0x7F (DEL) indicates that the password is not being used. * + * The password can only be set to a custom value if APN username has been configured. * + * Group: Data Server * + * Status: In use [06.11.00 - LATEST] */ string apn_password = 56; - /* Reserved by versions above 06.20.00 */ - reserved 57; + /* PLMN selection: * + * - [100:999999] - Selected operator code * + * - 1000000 - Automatic selection * + * - 0xFFFFFFFF - Automatic selection (legacy) * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 plmn_selection = 17; - /* Control output state on channel pin. Maximal number of requests equals 3 */ - /* This field is only sent by server */ - repeated ProtoOutputControlState output_control_state_request = 58; + /* Modem bands mask. * + * 4-byte bit mask, where each bit represents a specific band: * + * Band: | 1 | 2 | 3 | 4 | 5 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 25 | 26 | 28 | 66 | 71 | 85 | 70 | * + * Bit: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | * + * Example: To enable band 3, 8 and 20 set to 2084 = (1 << 2) + (1 << 5) + (1 << 11) * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 modem_bands_mask = 31; - /* BLE advertising period configuration. */ - ProtoBleAdvertisingPeriod ble_advertising_period = 59; -} + /* Modem configuration parameters. * + * 1st item - The number of cellular parameters that are used (depending on the modem in use). * + * 2nd - 16th item - Cellular parameters. * + * Group: Cellular Configuration * + * Status: In use [06.08.00 - LATEST] */ + repeated uint32 cellular_config_params = 46; + + /* Network search schema. * + * Group: Network Search * + * Status: In use [06.20.00 - LATEST] */ + ProtoNetworkSearch network_search = 57; + + /* USER INTERFACE CONFIGURATION --------------------------------------------------------------------------------------------- */ + + /* LEDs behavior. * + * Period of LED blinking in 5-second unit (value is multiplied by 5). Range: [1:120]: * + * - 1st item - Period of GREEN LED blinking * + * - 2nd item - Period of RED LED blinking * + * Time elapsing from the entry into the normal state, after which the LED indicator is switched off, in minutes. * + * Range: [0:240; 255]. Value of 255 means always on: * + * - 3rd item - Blinking RED LED on communication problem * + * - 4th item - Blinking RED LED on a sensor problem * + * - 5th item - Blinking RED LED on a low power * + * - 6th item - Blinking GREEN LED on measurement * + * - 7th item - Blinking GREEN LED on transmission * + * - 8th item - Blinking GREEN LED to indicate sensor's proper operation * + * 9th item - Duration of LED blinking in 5-millisecond unit. Range: [4:200] * + * Group: LED * + * Status: In use [06.10.00 - LATEST] */ + repeated uint32 led_config = 50; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_config_types.proto b/common/transport/coap/src/main/proto/efento/proto_config_types.proto new file mode 100644 index 0000000000..44fb9197a4 --- /dev/null +++ b/common/transport/coap/src/main/proto/efento/proto_config_types.proto @@ -0,0 +1,125 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +syntax = "proto3"; + +option java_package = "org.thingsboard.server.gen.transport.coap"; +option java_outer_classname = "ConfigTypesProtos"; + +message ProtoCalibrationParametersRequest { + + /* Request details. * + * Bitmask: * + * - Bit 0:2 - Requested channel number, range: [1:6] * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + uint32 calibration_request = 1; + + /* Channel assignment - the sensor code. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + uint32 channel_assignment = 2; + + /* Channel calibration parameters. * + * Up to 8 parameters supported. * + * If this field is empty, the sensor will send the current set of parameters. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + repeated int32 parameters = 3; +} + +message ProtoOutputControlState { + + /* Channel index. * + * Range: [0:5] * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 channel_index = 1; + + /* Channel output state: * + * - 1 - OFF * + * - 2 - ON * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 channel_state = 2; +} + +enum BleAdvertisingPeriodMode { + + /* Invalid value. */ + BLE_ADVERTISING_PERIOD_MODE_UNSPECIFIED = 0; + + /* Default mode. * + * Bluetooth advertising interval is set to 1022.5ms or a lower value, based on the continuous measurement period. */ + BLE_ADVERTISING_PERIOD_MODE_DEFAULT = 1; + + /* Normal mode. * + * Uses the value configured by the user from the 'normal' field. */ + BLE_ADVERTISING_PERIOD_MODE_NORMAL = 2; + + /* Fast mode. * + * Uses the value configured by the user from the 'fast' field. */ + BLE_ADVERTISING_PERIOD_MODE_FAST = 3; +} + +message ProtoBleAdvertisingPeriod { + + /* Bluetooth advertising mode. * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + BleAdvertisingPeriodMode mode = 1; + + /* Bluetooth advertising interval in normal mode, configured in steps of 0.625 ms. * + * Range: [32:16384] * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 normal = 2; + + /* Bluetooth advertising interval in fast mode, configured in steps of 0.625 ms. * + * Range: [32:16384] * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 fast = 3; +} + +enum AdvertisementManufacturerDataFormat { + + /* Invalid value. */ + ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_UNSPECIFIED = 0; + + /* Advertisement manufacturer specific data format 3. */ + ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_V3 = 1; + + /* Advertisement manufacturer specific data format 5. */ + ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_V5 = 2; +} + +message ProtoNetworkSearch { + + /* Timing schema, if successful registration since the last reset. * + * Length: 6 items. * + * 1st - 6th item - Time in minutes. Range: [1:255]. * + * Status: In use [06.20.00 - LATEST] */ + repeated uint32 time_schema_last_registration_ok = 1; + + /* Timing schema, if no successful registration since the last reset. * + * Length: 6 items. * + * 1st - 6th item - Time in minutes. Range: [1:255]. * + * Status: In use [06.20.00 - LATEST] */ + repeated uint32 time_schema_last_registration_not_ok = 2; + + /* Disable base period in minutes. * + * Disable time = 'disable_period_base' * counter (from 1 to 'counter_max'). * + * Range: [1:255] * + * Status: In use [06.20.00 - LATEST] */ + uint32 disable_period_base = 3; + + /* Disable counter maximum. * + * Range: [1:255] * + * Status: In use [06.20.00 - LATEST] */ + uint32 counter_max = 4; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_device_info.proto b/common/transport/coap/src/main/proto/efento/proto_device_info.proto index 3e1f066c59..f3908826ad 100644 --- a/common/transport/coap/src/main/proto/efento/proto_device_info.proto +++ b/common/transport/coap/src/main/proto/efento/proto_device_info.proto @@ -18,185 +18,330 @@ syntax = "proto3"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "DeviceInfoProtos"; +message ProtoRuntime +{ + + /* Up-time in seconds (since reset). * + * Status: In use [06.00 - LATEST] */ + uint32 up_time = 1; + + /* Message counters (since reset). * + * 1st item - Confirmable message attempts counter. * + * 2nd item - Non-confirmable message attempts counter. * + * 3rd item - Successful message counter. * + * Status: In use [06.00 - LATEST] */ + repeated uint32 message_counters = 2; + + /* MCU temperature in Celsius. * + * Status: In use [06.00 - LATEST] */ + sint32 mcu_temperature = 3; + + /* Battery voltage in mV. * + * - [0:65534] - Battery voltage in millivolts * + * - 65535 - No measurement * + * Status: In use [07.00.00 - LATEST] / Previously as min_battery_voltage [06.00 - 06.xx.xx] */ + uint32 battery_voltage = 4; + + /* MCU temperature in Celsius when minimum battery voltage was reached. * + * Status: Deprecated [06.00 - 06.xx.xx] */ + sint32 min_battery_mcu_temperature = 5; + + /* Battery reset timestamp in seconds since 1st of January 1970 (epoch time). * + * Status: Deprecated [06.00 - 06.xx.xx] */ + uint32 battery_reset_timestamp = 6; + + /* Maximum MCU temperature in Celsius. * + * Status: In use [06.00 - LATEST] */ + sint32 max_mcu_temperature = 7; + + /* Minimum MCU temperature in Celsius * + * Status: In use [06.00 - LATEST] */ + sint32 min_mcu_temperature = 8; + + /* Device's runtime errors. * + * Up to 20 error items supported. * + * Status: In use [06.02 - LATEST] */ + repeated uint32 runtime_errors = 9; + + /* Number of sensor resets (since power-up). * + * Status: In use [06.21.00 - LATEST] */ + uint32 reset_counter = 10; +} + +message ProtoUpdateInfo +{ + + /* Timestamp of the last update check in seconds since 1st of January 1970 (epoch time). * + * Status: In use [06.00 - LATEST] */ + uint32 timestamp = 1; + + /* Status of the last update check: * + * - 1 - No update check * + * - 2 - No error * + * - 3 - UDP socekt error * + * - 4 - Invalid hash * + * - 5 - Missing packet * + * - 6 - Invalid data * + * - 7 - Sending timeout * + * - 8 - No software to update * + * - 9 - Sending unexpected error * + * - 10 - Unexpected error * + * Status: In use [06.00 - LATEST] */ + uint32 status = 2; +} + enum ModemType { - /* Invalid value */ + /* Invalid value. */ MODEM_TYPE_UNSPECIFIED = 0; - /* Quectel BC66 modem */ + /* Quectel BC66 modem. */ MODEM_TYPE_BC66 = 1; - /* Quectel BC66-NA modem */ + /* Quectel BC66-NA modem. */ MODEM_TYPE_BC66NA = 2; + + /* Uses a shared modem from another sensor. */ + MODEM_TYPE_SHARED_MODEM = 3; + + /* Quectel BC660 modem. */ + MODEM_TYPE_BC660 = 4; } -message ProtoRuntime +enum ModemFirmwareVersion { + /* Invalid value. */ + MODEM_FIRMWARE_VERSION_UNSPECIFIED = 0; - /* Up-time in seconds (since reset) */ - uint32 up_time = 1; + /* Unable to read firmware version from device. */ + MODEM_FIRMWARE_VERSION_READING_ERROR = 1; - /* Message counters (since reset). There are 3 counters: */ - /* message_counters[0] - Counter of confirmable messages attempts */ - /* message_counters[1] - Counter of non-confirmable messages attempts */ - /* message_counters[2] - Counter of succeeded messages */ - repeated uint32 message_counters = 2; + /* Unknown firmware version. */ + MODEM_FIRMWARE_VERSION_UNKNOWN = 2; - /* MCU temperature in Celsius */ - sint32 mcu_temperature = 3; + /* BC660KGLAAR01A05_01.002.01.002. */ + MODEM_FIRMWARE_VERSION_BC660_V1 = 3; - /* Minimum battery voltage in mV */ - uint32 min_battery_voltage = 4; + /* BC660KGLAAR01A05_01.200.01.200. */ + MODEM_FIRMWARE_VERSION_BC660_V2 = 4; - /* MCU temperature in Celsius, while the minimum battery voltage was reached */ - sint32 min_battery_mcu_temperature = 5; + /* BC660KGLAAR01A05_01.202.01.202. */ + MODEM_FIRMWARE_VERSION_BC660_V3 = 5; - /* Battery reset timestamp (Unix timestamp) */ - uint32 battery_reset_timestamp = 6; + /* BC660KGLAAR01A05_01.203.01.203. */ + MODEM_FIRMWARE_VERSION_BC660_V4 = 6; - /* Max MCU temperature in Celsius */ - sint32 max_mcu_temperature = 7; + /* BC660KGLAAR01A05_01.204.01.204. */ + MODEM_FIRMWARE_VERSION_BC660_V5 = 7; - /* Min MCU temperature in Celsius */ - sint32 min_mcu_temperature = 8; + /* BC660KGLAAR01A05_01.205.01.205. */ + MODEM_FIRMWARE_VERSION_BC660_V6 = 8; - /* Table of runtime errors. Max length: 20 */ - repeated uint32 runtime_errors = 9; + /* BC660KGLAAR01A05_01.301.01.301. */ + MODEM_FIRMWARE_VERSION_BC660_V7 = 9; + + /* BC660KGLAAR01A05_01.303.01.303. */ + MODEM_FIRMWARE_VERSION_BC660_V8 = 10; } message ProtoModem { + /* Modem type. * + * Status: In use [06.00 - LATEST] */ ModemType type = 1; - /* Parameters for BC66 modem: */ - /* parameters[0] - sc_EARFCN - Range: [0:262143]. Unknown value: -1 */ - /* parameters[1] - sc_EARNFCN_offset - Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 */ - /* parameters[2] - sc_PCI - Range: [0:502]. Unknown value: -1 */ - /* parameters[3] - sc_Cell id - Range: [1:268435456]. Unknown value: 0 */ - /* parameters[4] - sc_RSRP - [dBm] - Range: [-140:-44]. Unknown value: 0 */ - /* parameters[5] - sc_RSRQ - [dB] - Range: [-20:-3]. Unknown value: 0 */ - /* parameters[6] - sc_RSSI - [dBm] - Range: [-110:-3] Unknown value: 0 */ - /* parameters[7] - sc_SINR - [dB] - Range: [-10:30]. Unknown value: 31 */ - /* parameters[8] - sc_Band - Range: [see module supported bands]. The current serving cell band. Unknown value: -1 */ - /* parameters[9] - sc_TAC - Range: [0:65536]. Unknown value: -1 */ - /* parameters[10] - sc_ECL - Range: [0:2]. Unknown value: -1 */ - /* parameters[11] - sc_TX_PWR - [0.1cBm] - Range [-440:230]. Unknown value: -1000 */ - /* parameters[12] - OP_MODE - Range: [0:3]. Unknown value: -1 */ - /* parameters[13] - nc_EARFCN - Range: [0:262143]. Unknown value: -1 */ - /* parameters[14] - nc_EARNFCN_offset - Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 */ - /* parameters[15] - nc_PCI - Range: [0:502]. Unknown value: -1 */ - /* parameters[16] - nc_RSRP - [dBm] - Range: [-140:-44]. Unknown value: 0 */ - /* parameters[17] - RLC_UL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[18] - RLC_DL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[19] - MAC_UL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[20] - MAC_DL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[21] - MAC_UL_TOTAL_BYTES - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[22] - MAC_DL_TOTAL_BYTES - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[23] - MAC_UL_total_HARQ_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[24] - MAC_DL_total_HARQ_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[25] - MAC_UL_HARQ_re_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[26] - MAC_DL_HARQ_re_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[27] - RLC_UL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[28] - RLC_DL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[29] - MAC_UL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[30] - MAC_DL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[31] - sleep_duration - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[32] - Rx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[33] - Tx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ + /* Modem runtime parameters. * + * For BC66 modem (34 parameters): * + * - 1st item - sc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 2nd item - sc_EARNFCN_offset. Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 * + * - 3rd item - sc_PCI. Range: [0:502]. Unknown value: -1 * + * - 4th item - sc_Cell_Id. Range: [1:268435456]. Unknown value: 0 * + * - 5th item - sc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 6th item - sc_RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 7th item - sc_RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 8th item - sc_SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * - 9th item - sc_Band. Range: [see module supported bands]. The current serving cell band. Unknown value: -1 * + * - 10th item - sc_TAC. Range: [0:65536]. Unknown value: -1 * + * - 11th item - sc_ECL. Range: [0:2]. Unknown value: -1 * + * - 12th item - sc_TX_PWR [0.1dBm]. Range [-440:230]. Unknown value: -1000 * + * - 13th item - OP_MODE. Range: [0:3]. Unknown value: -1 * + * - 14th item - nc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 15th item - nc_EARNFCN_offset. Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 * + * - 16th item - nc_PCI. Range: [0:502]. Unknown value: -1 * + * - 17th item - nc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 18th item - RLC_UL_BLER. Range: [0:100]. Unknown value: -1 * + * - 19th item - RLC_DL_BLER. Range: [0:100]. Unknown value: -1 * + * - 20th item - MAC_UL_BLER. Range: [0:100]. Unknown value: -1 * + * - 21th item - MAC_DL_BLER. Range: [0:100]. Unknown value: -1 * + * - 22th item - MAC_UL_TOTAL_BYTES. Range: [0:2147483647]. Unknown value: -1 * + * - 23th item - MAC_DL_TOTAL_BYTES. Range: [0:2147483647]. Unknown value: -1 * + * - 24th item - MAC_UL_total_HARQ_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 25th item - MAC_DL_total_HARQ_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 26th item - MAC_UL_HARQ_re_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 27th item - MAC_DL_HARQ_re_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 28th item - RLC_UL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 29th item - RLC_DL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 30th item - MAC_UL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 31th item - MAC_DL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 32th item - sleep_duration [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 33th item - Rx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 34th item - Tx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * For BC660 modem (22 parameters): * + * - 1st item - sc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 2nd item - sc_EARNFCN_offset. Range: [0:21] mapped to * + * [Invalid, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, -0.5, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]. * + * Unknown value: -1 * + * - 3rd item - sc_PCI. Range: [0:503]. Unknown value: -1 * + * - 4th item - sc_Cell_Id. Range: [1:268435456]. Unknown value: 0 * + * - 5th item - sc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 6th item - sc_RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 7th item - sc_RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 8th item - sc_SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * - 9th item - sc_Band. Range: [see module supported bands]. The current serving cell band. Unknown value: -1 * + * - 10th item - sc_TAC. Range: [0:65536]. Unknown value: -1 * + * - 11th item - sc_ECL. Range: [0:2]. Unknown value: -1 * + * - 12th item - sc_TX_PWR [dBm]. Range [-45:23]. Unknown value: 128 * + * - 13th item - OP_MODE. Range: [0:3] mapped to [In-band same PCI, In-band different PCI, Guard band, Stand alone]. * + * Unknown value: -1 * + * - 14th item - nc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 15th item - nc_PCI. Range: [0:503]. Unknown value: -1 * + * - 16th item - nc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 17th item - nc_RSRQ [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 18th item - sleep_duration [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 19th item - Rx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 20th item - Tx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 21st item - PLMN_state. Range: [0:3] mapped to [No PLMN, Searching, Selected, Unknown]. Unknown value: 3 * + * - 22nd item - select_PLMN. Range: [100:999999]. Unknown value: -1 * + * For shared modem client (4 parameters): * + * - 1st item - RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 2nd item - RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 3rd item - RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 4th item - SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * Status: In use [06.00 - LATEST] */ repeated sint32 parameters = 2; - /* ICCID of inserted/soldered sim card. String up to 22 characters long. */ - /* 0x7F if sim card is not detected, empty (not sent) if device does not have modem. */ - /* This field is only sent by device */ + /* Integrated Circuit Card Identifier (ICCID) of the inserted/soldered SIM card. * + * String up to 22 characters: * + * A string containing only the special character 0x7F (DEL) indicates that the SIM card is not detected. * + * Status: In use [06.07.04/06.09.09/06.10.10/06.11.09/06.12.06/06.13.00/06.21.00 - LATEST] */ string sim_card_identification = 3; + + /* Modem firmware version. * + * Status: In use [06.20.05/06.21.00 - LATEST] */ + ModemFirmwareVersion firmware_version = 4; + + /* Modem identification: * + * - IMEI (International Mobile Equipment Identity) of the modem * + * - Serial number of the most recently used modem-sharing sensor * + * String up to 15 characters. * + * Status: In use [07.00.00 - LATEST] */ + string modem_identification = 5; + + /* Modem statistics. * + * 1st item - Number of transmissions since the last device information message. Undefined value: 0. * + * 2nd item - Time in seconds since the last device information message. Undefined value: 0. * + * 3rd item - Total time in the power saving mode, in seconds. Undefined value: 0. * + * 4th item - Total time in the active state in seconds. Undefined value: 0. * + * Status: In use [07.00.00 - LATEST] */ + repeated uint32 modem_statistics = 6; } -message ProtoUpdateInfo +message ProtoDeviceInfo { - /* Timestamp of update (Unix timestamp) */ - uint32 timestamp = 1; + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ - /* Update status, possible values: */ - /* - 1 - No update yet */ - /* - 2 - No error */ - /* - 3 - UDP socekt error */ - /* - 4 - Hash error */ - /* - 5 - Missing packet error */ - /* - 6 - Invalid data error */ - /* - 7 - Sending timeout error */ - /* - 8 - No SW to update error */ - /* - 9 - Sending unexpected error */ - /* - 10 - Unexpected error */ - uint32 status = 2; -} + reserved 2,4,5,6,7,8,9,10,11,12; -message ProtoDeviceInfo -{ + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ - /* Serial number of device */ - bytes serial_num = 1; + /* Serial number of the device. * + * Length: 6 bytes. * + * Status: In use [07.00.00 - LATEST] / Previously as serial_num [06.00 - 06.xx.xx] */ + bytes serial_number = 1; - /* Deprecated field */ - reserved 2; + /* DEVICE INFORMATION FIELDS ------------------------------------------------------------------------------------------------ */ - /* Software version e.g ver 06.10 -> 0x060A -> 1546 */ + /* Software version (excluding LTS). * + * Example: 1546 -> 0x060A -> SW version 06.10 * + * Status: In use [06.00 - LATEST] */ uint32 sw_version = 3; - /* Deprecated fields */ - reserved 4,5,6,7,8,9,10,11,12; + /* Software commit ID and LTS version. * + * String of 7 characters: * + * - Commit ID (examples: "fa02cd0" means the beginning of the commit ID "fa02cd0") [06.00 - 06.06.xx] * + * - LTS version and commit ID (examples: "0bdd23f" means LTS version 11 and the beginning of the commit ID "dd23f") * + [06.07.00 - LATEST] * + * Status: In use [06.00 - LATEST] */ + string commit_id = 15; - /* Structure with battery and temperature information */ + /* Device runtime information. * + * Status: In use [06.00 - LATEST] */ ProtoRuntime runtime_info = 13; - /* Structure with modem specific runtime information */ + /* Memory statistics: * + * 1st item - Status of the non-volatile storage: * + * - 0 - Non-volatile storage works properly * + * - 1 - Non-volatile storage has some corrupt packets. Memory is read-only * + * - 2 - Non-volatile storage is corrupted. Memory is unavailable * + * 2nd item - Timestamp of the end of collecting statistics in seconds since 1st of January 1970 (epoch time). * + Undefined value: 4294967295. * + * 3rd item - Capacity of the memory in bytes. * + * 4th item - Used space in bytes. * + * 5th item - Size of invalid (outdated) packets in bytes. * + * 6th item - Size of corrupt packets in bytes. * + * 7th item - Number of valid packets. * + * 8th item - Number of invalid (outdated) packets. * + * 9th item - Number of corrupt packets. * + * 10th item - Number of all samples for channel 1 (valid packets). * + * 11th item - Number of all samples for channel 2 (valid packets). * + * 12th item - Number of all samples for channel 3 (valid packets). * + * 13th item - Number of all samples for channel 4 (valid packets). * + * 14th item - Number of all samples for channel 5 (valid packets). * + * 15th item - Number of all samples for channel 6 (valid packets). * + * 16th item - Timestamp of the first binary measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 17th item - Timestamp of the last binary measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 18th item - Timestamp of the last binary measurement marked as sent, in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 19th item - Timestamp of the first continuous measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 20th item - Timestamp of the last continuous measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 21th item - Timestamp of the last continuous measurement marked as sent, in seconds since 1st of January 1970 (epoch time).* + * Undefined value: 4294967295. * + * 22th item - NvM write counter. * + * Status: In use [06.02 - LATEST] */ + repeated uint32 memory_statistics = 17; + + /* Last update check information. * + * Status: In use [06.08.00 - LATEST] */ + ProtoUpdateInfo last_update_info = 18; + + /* Modem information. * + * Only used if the sensor has a modem. * + * Status: In use [06.00 - LATEST] */ ProtoModem modem = 14; - /* String up to 7 bytes long. Software commit id e.g. "e0e8556" */ - /* From version 06.07 the first two characters indicate the LTS version. */ - /* For example: the value "0bdd23f" means LTS version 11 and the beginning of the commit ID "dd23f" */ - string commit_id = 15; + /* SERVER COMMUNICATION FIELDS ---------------------------------------------------------------------------------------------- */ - /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ + /* Cloud token. * + * Can be empty, set to any user-defined value, or hold device IMEI. * + * String up to 36 characters. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ string cloud_token = 16; - /* Memory statistics: */ - /* memory_statistics[0] - Status of Nv storage: */ - /* - 0 - Nv storage hasn't errors */ - /* - 1 - Nv storage has some corrupted packet. Memory is read-only */ - /* - 2 - Nv storage is corrupted. Memory is unavailable */ - /* memory_statistics[1] - Timestamp of the end of collecting statistics. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[2] - Capacity of memory in bytes */ - /* memory_statistics[3] - Used space in bytes */ - /* memory_statistics[4] - Size of invalid (outdated) packets in bytes */ - /* memory_statistics[5] - Size of corrupted packets in bytes */ - /* memory_statistics[6] - Number of valid packets */ - /* memory_statistics[7] - Number of invalid (outdated) packets */ - /* memory_statistics[8] - Number of corrupted packets */ - /* memory_statistics[9] - Number of all samples for channel 1 (valid packets) */ - /* memory_statistics[10] - Number of all samples for channel 2 (valid packets) */ - /* memory_statistics[11] - Number of all samples for channel 3 (valid packets) */ - /* memory_statistics[12] - Number of all samples for channel 4 (valid packets) */ - /* memory_statistics[13] - Number of all samples for channel 5 (valid packets) */ - /* memory_statistics[14] - Number of all samples for channel 6 (valid packets) */ - /* memory_statistics[15] - Timestamp of the first binary measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[16] - Timestamp of the last binary measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[17] - Timestamp of the last binary measurement, that marked as sent. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[18] - Timestamp of the first continuous measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[19] - Timestamp of the last continuous measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[20] - Timestamp of the last continuous measurement, that marked as sent. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[21] - NVM write counter */ - repeated uint32 memory_statistics = 17; - - /* Information about last sensor SW update */ - ProtoUpdateInfo last_update_info = 18; + /* Enclosure tamper alert: * + * - 0 - Inactive * + * - 1 - Active (enclosure opened) * + * - 2 - Inactive, was active (enclosure closed) * + * The transition from 'Inactive, was active' to 'Inactive' occurs after the alert has been successfully sent to the server * + * or the user has reset the alert via Bluetooth. * + * This field is only sent during server communication. * + * Status: In use [07.00.01 - LATEST] */ + uint32 enclosure_tamper_alert = 19; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto index fc72cc10e3..7e7e908e16 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto @@ -19,160 +19,201 @@ option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "MeasurementTypeProtos"; enum MeasurementType { - /* [] - No sensor on the channel */ + + /* [] - No sensor on the channel. */ MEASUREMENT_TYPE_NO_SENSOR = 0; - /* [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2:4000.0]. Type: Continuous */ + /* [°C] - Celsius degree. Temperature. Resolution: 0.1°C. Range: [-273.2:4000.0]. Type: Continuous. */ MEASUREMENT_TYPE_TEMPERATURE = 1; - /* [% RH] - Relative humidity. Resolution 1%. Range [0:100]. Type: Continuous */ + /* [% RH] - Percentage. Relative humidity. Resolution: 1%. Range: [0:100]. Type: Continuous. */ MEASUREMENT_TYPE_HUMIDITY = 2; - /* [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0:2000.0]. Atmospheric pressure. Type: Continuous */ + /* [hPa] - Hectopascal. Atmospheric pressure. Resolution: 0.1hPa. Range: [1.0:2000.0]. Type: Continuous. */ MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE = 3; - /* [Pa] - Pascal. Resolution 1Pa. Range [-10000:10000]. Differential pressure. Type: Continuous */ + /* [Pa] - Pascal. Differential pressure. Resolution: 1Pa. Range: [-10000:10000]. Type: Continuous. */ MEASUREMENT_TYPE_DIFFERENTIAL_PRESSURE = 4; - /* Sign indicates state: (+) ALARM, (-) OK. Type: Binary */ + /* Sign indicates state: (+) Alarm, (-) OK. Type: Binary. */ MEASUREMENT_TYPE_OK_ALARM = 5; - /* [IAQ] - IAQ index. Resolution 1IAQ. Range [0:500]. To get IAQ index the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [IAQ] - IAQ index. Air quality. Resolution: 1IAQ. Range: [0:500]. Type: Continuous. * + * To obtain the IAQ index, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_IAQ = 6; - /* Sign indicates water presence: (+) water not detected, (-) water detected. Type: Binary */ + /* Sign indicates state: (+) Water detected, (-) Water not detected. Type: Binary. */ MEASUREMENT_TYPE_FLOODING = 7; - /* [NB] Number of pulses. Resolution 1 pulse. Range [0:8000000]. Type: Continuous */ + /* [NB] - Number of pulses. Number of pulses in a single period. Resolution: 1 pulse. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_PULSE_CNT = 8; - /* [Wh] - Watthour; Resolution 1Wh. Range [0:8000000]. Number of Watthours in a single period. Type: Continuous */ + /* [Wh] - Watt-hour. Number of watt-hours in a single period. Resolution: 1Wh. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_ELECTRICITY_METER = 9; - /* [l] - Liter. Resolution 1l. Range [0:8000000]. Number of litres in a single period. Type: Continuous */ + /* [l] - Liter. Number of litres in a single period. Resolution: 1l. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_WATER_METER = 10; - /* [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000:0]. Soil moisture (tension). Type: Continuous */ + /* [kPa] - Kilopascal. Soil moisture (tension). Resolution: 1kPa. Range: [-1000:0]. Type: Continuous. */ MEASUREMENT_TYPE_SOIL_MOISTURE = 11; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon monoxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Carbon monoxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_CO_GAS = 12; - /* [ppm] - Parts per million. Resolution 1.0ppm. Range [0:1000000]. Nitrogen dioxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Nitrogen dioxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_NO2_GAS = 13; - /* [ppm] - Parts per million. Resolution 0.01ppm. Range [0.00:80000.00]. Hydrogen sulfide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Hydrogen sulfide concentration. Resolution: 0.01ppm. Range: [0.00:80000.00]. Type: Continuous. */ MEASUREMENT_TYPE_H2S_GAS = 14; - /* [lx] - Lux. Resolution 0.1lx. Range [0.0:100000.0]. Illuminance. Type: Continuous */ + /* [lx] - Lux. Illuminance. Resolution: 0.1lx. Range: [0.0:100000.0]. Type: Continuous. */ MEASUREMENT_TYPE_AMBIENT_LIGHT = 15; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 1 micrometer. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 1 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_1_0 = 16; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 2.5 micrometers. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 2.5 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_2_5 = 17; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 10 micrometers. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 10 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_10_0 = 18; - /* [dB] - Decibels. Resolution 0.1 dB. Range: [0.0:200.0]. Noise level. Type: Continuous */ + /* [dB] - Decibel. Noise level. Resolution: 0.1 dB. Range: [0.0:200.0]. Type: Continuous. */ MEASUREMENT_TYPE_NOISE_LEVEL = 19; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Ammonia concentration. Type: Continuous */ + /* [ppm] - Parts per million. Ammonia concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_NH3_GAS = 20; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Methane concentration. Type: Continuous */ + /* [ppm] - Parts per million. Methane concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_CH4_GAS = 21; - /* [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [0:200000]. Pressure. Type: Continuous */ + /* [kPa] - Kilopascal (100kPa = 1bar). Pressure. Resolution: 1kPa. Range: [0:200000]. Type: Continuous. */ MEASUREMENT_TYPE_HIGH_PRESSURE = 22; - /* [mm] - Millimeter. Resolution 1mm. Range [0:100000]. Distance. Type: Continuous */ + /* [mm] - Millimeter. Distance. Resolution: 1mm. Range: [0:100000]. Type: Continuous. */ MEASUREMENT_TYPE_DISTANCE_MM = 23; - /* [l] - Liter. Resolution 1l. Range [0:1000000]. Accumulative water meter (minor). Type: Continuous */ + /* [l] - Liter. Water meter (minor). Resolution: 1l. Range: [0:99]. Type: Continuous. * + * To obtain the liters, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_WATER_METER_ACC_MINOR = 24; - /* [hl] - Hectoliter. Resolution 1hl. Range [0:1000000]. Accumulative water meter (major). Type: Continuous */ + /* [hl] - Hectoliter. Water meter (major). Resolution: 1hl. Range: [0:999999]. Type: Continuous. * + * To obtain the hectoliters, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_WATER_METER_ACC_MAJOR = 25; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Carbon dioxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. * + * To obtain the carbon dioxide concentration, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Auto calibration has not yet been performed * + * - 1 - The last auto calibration was successful * + * - 2 - Last auto calibration failed */ MEASUREMENT_TYPE_CO2_GAS = 26; - /* [% RH] - Relative humidity. Resolution 0.1%. Range [0.0:100.0]. Type: Continuous */ + /* [% RH] - Percentage. Relative humidity (accurate). Resolution: 0.1%. Range: [0.0:100.0]. Type: Continuous. */ MEASUREMENT_TYPE_HUMIDITY_ACCURATE = 27; - /* [sIAQ] - Static IAQ index. Resolution 1IAQ. Range [0:10000]. To get static IAQ index the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [sIAQ] - Static IAQ index. Air quality. Resolution: 1sIAQ. Range: [0:10000]. Type: Continuous. * + * To obtain the static IAQ index, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_STATIC_IAQ = 28; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. CO2 equivalent. */ - /* To get CO2 equivalent the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [ppm] - Parts per million. CO2 equivalent. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. * + * To obtain the CO2 equivalent, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_CO2_EQUIVALENT = 29; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ - /* To get breath VOC estimate the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [ppm] - Parts per million. Breath VOC estimate. Resolution: 1ppm. Range: [0:100000]. Type: Continuous. * + * To obtain the breath VOC estimate, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_BREATH_VOC = 30; - /* Special measurement type reserved for cellular gateway. */ - /* Type: Continuous */ - MEASUREMENT_TYPE_CELLULAR_GATEWAY = 31; + /* Reserved for the Shared Modem functionality. Type: Continuous. */ + MEASUREMENT_TYPE_SHARED_MODEM = 31; - /* [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. Type: Continuous */ + /* [%] - Percentage. Generic type. Resolution: 0.01%. Range: [0.00:100.00]. Type: Continuous. */ MEASUREMENT_TYPE_PERCENTAGE = 32; - /* [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. Type: Continuous */ + /* [mV] - Millivolt. Voltage. Resolution: 0.1mV. Range: [0.0:100000.0]. Type: Continuous. */ MEASUREMENT_TYPE_VOLTAGE = 33; - /* [mA] - Milliampere. Resolution 0.01mA. Range [0.0:10000.00]. Type: Continuous */ + /* [mA] - Milliampere. Current. Resolution: 0.01mA. Range: [0.00:10000.00]. Type: Continuous. */ MEASUREMENT_TYPE_CURRENT = 34; - /* [NB] Number of pulses. Resolution 1 pulse. Range [0:1000000]. Type: Continuous */ + /* [NB] - Number of pulses. Pulse counter (minor). Resolution: 1 pulse. Range: [0:999]. Type: Continuous. * + * To obtain the number of pulses, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_PULSE_CNT_ACC_MINOR = 35; - /* [kNB] Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. Type: Continuous */ + /* [kNB] - Number of kilopulses. Pulse counter (major). Resolution: 1 kilopulse. Range: [0:999999]. Type: Continuous. * + * To obtain the number of kilopulses, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_PULSE_CNT_ACC_MAJOR = 36; - /* [Wh] - Watt-hour; Resolution 1Wh. Range [0:1000000]. Number of watt-hours in a single period. Type: Continuous */ + /* [Wh] - Watt-hour. Electricity meter (minor). Resolution: 1Wh. Range: [0:999]. Type: Continuous. * + * To obtain the watt-hours, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_ELEC_METER_ACC_MINOR = 37; - /* [kWh] - Kilowatt-hour; Resolution 1kWh. Range [0:1000000]. Number of kilowatt-hours in a single period. Type: Continuous */ + /* [kWh] - Kilowatt-hour. Electricity meter (major). Resolution: 1kWh. Range: [0:999999]. Type: Continuous. * + * To obtain the kilowatt-hours, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_ELEC_METER_ACC_MAJOR = 38; - /* [NB] Number of pulses (wide range). Resolution 1 pulse. Range [0:999999]. Type: Continuous */ + /* [NB] - Number of pulses. Wide-range pulse counter (minor). Resolution: 1 pulse. Range: [0:999999]. Type: Continuous. * + * To obtain the number of pulses, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MINOR = 39; - /* [MNB] Number of megapulses (wide range). Resolution 1 megapulse. Range [0:999999]. Type: Continuous */ + /* [MNB] - Number of megapulses. Wide-range pulse counter (major). Resolution: 1 megapulse. Range: [0:999999]. * + * Type: Continuous. * + * To obtain the number of megapulses, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MAJOR = 40; - /* [mA] - Milliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. Type: Continuous */ + /* [mA] - Milliampere. Current (precise). Resolution: 0.001mA. Range: [-4000.000:4000.000]. Type: Continuous. */ MEASUREMENT_TYPE_CURRENT_PRECISE = 41; - /* Sign indicates state: (+) ON, (-) OFF. Type: Binary */ + /* Sign indicates state: (+) ON, (-) OFF. Type: Binary. */ MEASUREMENT_TYPE_OUTPUT_CONTROL = 42; -} - + /* [Ω] - Ohm. Resolution: 1Ω. Range: [0:1000000]. Type: Continuous. */ + MEASUREMENT_TYPE_RESISTANCE = 43; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_measurements.proto b/common/transport/coap/src/main/proto/efento/proto_measurements.proto index 650487ff29..12626d4834 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurements.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurements.proto @@ -14,6 +14,7 @@ * limitations under the License. */ syntax = "proto3"; + import "efento/proto_measurement_types.proto"; option java_package = "org.thingsboard.server.gen.transport.coap"; @@ -21,106 +22,166 @@ option java_outer_classname = "MeasurementsProtos"; message ProtoChannel { - /* Type of channel */ + /* Reserved fields. */ + reserved 6,7,8; + + /* Type of measurement. * + * Status: In use [06.00 - LATEST] */ MeasurementType type = 1; - /* Timestamp of the first sample (the oldest one) in seconds since UNIX EPOCH 01-01-1970 */ + /* Timestamp of the first (oldest) sample in seconds since 1st of January 1970 (epoch time). * + * Status: In use [06.00 - LATEST] */ int32 timestamp = 2; - /* Only used for 'Continuous' sensor types. Value used as the starting point for calculating the values of all */ - /* measurements in the package. */ - /* Format defined by 'MeasurementType' field */ + /* Start point of measurement values. * + * This is used as the base value for calculating the values of all channel measurements in the message. * + * Used for 'Continuous' sensor types only. * + * Format is defined by 'MeasurementType' field. * + * Status: In use [06.00 - LATEST] */ sint32 start_point = 4; - /* 'Continuous' sensor types */ - /* Value of the offset from the 'start_point' for each measurement in the package. The oldest sample first ([0]). */ - /* 'sample_offsets' format defined by 'MeasurementType' field. */ - /* If the 'sample_offset' has a value from the range [8355840: 8388607], it should be interpreted as a sensor error code. */ - /* In that case value of the 'start_point' field should not be added to this 'sample_offset'. See ES6-264 for error codes. */ - /* Example: MeasurementType = 1 (temperature), start_point = 100, sample_offsets[0] = 15, sample_offsets[1] = 20, */ - /* sample_offset[2] = 8388605 */ - /* 1st sample in the package temperature value = 11.5 °C, 2nd sample in the package temperature value = 12 °C */ - /* 3rd sample in the package has no temperature value. It has information about failure of MCP9808 (temperature) sensor. */ - /* Calculating timestamps of the measurements: timestamp = 1606391700, measurement_period_base = 60, */ - /* measurement_period_factor = 1. Timestamp of the 1st sample = 1606391700, timestamp of the 2nd sample = 1606391760, */ - /* timestamp of the 3rd sample 1606391820 */ - - /* 'Binary' sensor types: */ - /* Absolute value of the 'sample_offsets' field indicates the offset in seconds from 'timestamp' field. */ - /* Sign (- or +) indicates the state of measurements depending on the sensor type. */ - /* Value of this field equals to '1' or '-1' indicates the state at the 'timestamp'. Other values */ - /* indicate the state of the relay at the time (in seconds) equal to 'timestamp' + absolute value -1. */ - /* Values of this field are incremented starting from 1 (1->0: state at the time */ - /* of 'timestamp', 2->1: state at the time equal to 'timestamp' + 1 s, 3->2 : */ - /* state at the time equal to 'timestamp' + 2 s, etc.). The first and the last sample define the time range of the */ - /* measurements. Only state changes in the time range are included in the 'sample_offsets' field */ - /* Examples: if 'timestamp' value is 1553518060 and 'sample_offsets' equals '1', it means that at 1553518060 the state */ - /* was high, if 'timestamp' value is 1553518060 and 'sample_offsets' equals '-9', it means at 1553518068 the state was low */ + /* Measurement value offsets. * + * For 'Continuous' sensor types: * + * These are the values of the offsets from the 'start_point' for each channel measurement in the message. * + * Format is defined by 'MeasurementType' field. The first sample is the oldest. * + * If the sample offset has a value in the range [8355840:8388607], it should be interpreted as a sensor error code. * + * In this case, the value of the 'start_point' field should not be added to this sample offset. * + * Example: type = 1 (temperature) * + * start_point = 100 * + * sample_offsets[0] = 15, sample_offsets[1] = 20, sample_offsets[2] = 8388557 * + * timestamp = 1606391700 * + * measurement_period_base = 60 * + * measurement_period_factor = 1 * + * 1st measurement is the temperature value = 11.5°C, measured at 1606391700 * + * 2nd measurement is the temperature value = 12°C, measured at 1606391760 * + * 3rd measurement is the error code (failure of the SHT4x temperature sensor), measured at 1606391820 * + * For 'Binary' sensor types: * + * The absolute value of the sample offset minus 1 is the offset in seconds from the 'timestamp' field. * + * Sign (- or +) indicates the state of the measurement depending on the sensor type. * + * The first and the last samples define the time range of the measurements. * + * Only state changes within the time range are included in the 'sample_offsets' field. * + * Example: type = 5 (OK/Alarm) * + * sample_offsets[0] = 1, sample_offsets[1] = -9, sample_offsets[2] = 13 * + * timestamp = 1606391700 * + * 1st measurement is the state at 1606391700, which is Alarm (+) * + * 2nd measurement is the state at 1606391708, which is OK (-) * + * 3rd measurement is the state at 1606391712, which is Alarm (+) * + * Status: In use [06.00 - LATEST] */ repeated sint32 sample_offsets = 5 [packed=true]; - - /* Deprecated - configuration is sent to endpoint 'c' */ - /* int32 lo_threshold = 6; */ - reserved 6; - - /* Deprecated - configuration is sent to endpoint 'c' */ - /* int32 hi_threshold = 7; */ - reserved 7; - - /* Deprecated - configurations sent to endpoint 'c' */ - /* int32 diff_threshold = 8; */ - reserved 8; } message ProtoMeasurements { - /* Serial number of the device */ - bytes serial_num = 1; - - /* Battery status: true - battery ok, false - battery low */ - bool battery_status = 2; - - /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ - /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ - /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ - /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ - /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ - /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ - /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ - /* measurement period base in seconds */ + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ + + reserved 10,11,12,13,14,15; + + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ + + /* Serial number of the device. * + * Length: 6 bytes. * + * Status: In use [07.00.00 - LATEST] / Previously as serial_num [06.00 - 06.xx.xx] */ + bytes serial_number = 1; + + /* Identifier of the current configuration. * + * The value of this field changes with every configuration change. * + * Status: In use [07.00.00 - LATEST] / Previously as hash [06.00 - 06.xx.xx] */ + uint32 configuration_hash = 9; + + /* Identifier of the current extended configuration. * + * The value of this field changes with every extended configuration change. * + * Status: In use [07.00.00 - LATEST] */ + uint32 extended_configuration_hash = 17; + + /* Battery level and status: * + * - [1:65525] - Battery voltage measurement and a battery status * + * - [65526:65531] - No battery voltage measurement and a battery status * + * To obtain the battery voltage, the battery_level value should be divided by 6 and the remainder of the division discarded. * + * The result is in hundredths of a volt or 10921 (no battery voltage measurement). * + * Battery level also includes a battery status as metadata (the remainder of the value divided by 6): * + * - 0 - Device is powered by a battery * + * - 1 - Device is powered by a rechargeable battery * + * - 2 - Device is powered by an external source and a battery is charging * + * - 3 - Reserved for the future use * + * - 4 - Reserved for the future use * + * - 5 - Reserved for the future use * + * Example: battery_level = 2474 * + * Battery voltage = 2474/6 = 412 = 4.12[V] * + * Battery status = 2474 % 6 = 2 * + * Status: In use [07.00.00 - LATEST] */ + uint32 battery_level = 18; + + /* MEASUREMENT FIELDS ------------------------------------------------------------------------------------------------------- */ + + /* Measurement period defines how often the measurements are to be taken. * + * Sensors of 'Continuous' type take measurement each 'measurement_period_base' * 'measurement_period_factor'. * + * Sensors of 'Binary' type take measurement each 'measurement_period_base'. * + * For backward compatibility with versions 5.xx in case of 'Binary/Mixed' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '14' shall be used for the period calculation. * + * For backward compatibility with versions 5.xx in case of 'Continuous' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '1' shall be used for the period calculation. */ + + /* Measurement period base in seconds. * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_base = 3; - /* Measurement period factor */ + /* Measurement period factor. * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_factor = 8; + /* Measurements grouped by channel. * + * Status: In use [06.00 - LATEST] */ repeated ProtoChannel channels = 4; - /* Timestamp of the next scheduled transmission. If the device will not send data until this time, */ - /* it should be considered as 'lost' */ + /* SERVER COMMUNICATION FIELDS ---------------------------------------------------------------------------------------------- */ + + /* Battery status: * + * - True - Battery OK * + * - False - Battery discharged * + * Status: In use [06.00 - LATEST] */ + bool battery_status = 2; + + /* Timestamp of the next scheduled transmission. * + * If the device has not sent any data by this time, it should be considered 'lost'. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ uint32 next_transmission_at = 5; - /* Reason of transmission - unsigned integer where each bit indicates different possible communication reason. */ - /* Can be more than one: */ - /* - bit 0: first message after sensor reset */ - /* - bit 1: user button triggered */ - /* - bit 2: user BLE triggered */ - /* - bit 3-7: number of retries -> incremented after each unsuccessful transmission. Max value 31. */ - /* Set to 0 after a successful transmission. */ - /* - bit 8...19: rule 1...12 was met */ - /* - bit 20: triggered after the end of the limit */ + /* Reason for transmission. * + * Bitmask, where each bit represents a specific reason for initiating communication (multiple reasons can be active * + * simultaneously): * + * - Bit 0 - First message after sensor reset * + * - Bit 1 - User-triggered (button press) * + * - Bit 2 - User-triggered (Bluetooth) * + * - Bit 3:7 - Number of retries - incremented after each failed transmission and reset upon success. Range: [0:31] * + * - Bit 8:19 - Rule-based triggers (12 bits): * + * - [06.00 - 07.01.xx]: Bitmask indicating which of the rules 1-12 were met * + * - [07.02.00 - LATEST]: The transfer bit 'i' (where i = 0 to 11) is set if either rule 'i' (0-11) is set * + * or if rule 'i + 12' (12-15) is set. This aggregation applies only to bits 0-3 (rules 0-3 and 12-15). * + * The mask aggregates 16 rules (0-15) onto 12 bits. * + * - Bit 20 - Triggered at the end of the defined limit * + * - Bit 21 - Triggered after a PIN tamper alert * + * - Bit 22 - Triggered after an enclosure tamper alert * + * - Bit 23 - Triggered before shutdown * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ uint32 transfer_reason = 6; - /* Signal strength level mapped from RSSI: */ - /* - 0: RSSI < -110 dBm */ - /* - 1: -110 dBm <= RSSI < -109 dBm */ - /* - 2...61: -109 <= RSSI < -108 dBm ... -50 dBm <= RSSI < -49 dBm */ - /* - 62: -49 dBm <= RSSI < -48 dBm */ - /* - 63: RSSI >= -48 dBm */ - /* - 99: Not known or not detectable */ + /* Signal strength (RSSI - Received Signal Strength Level): * + * - 0 - RSSI < -110dBm * + * - 1 - -110dBm <= RSSI < -109dBm * + * - 2:61 - -109dBm <= RSSI < -108dBm ... -50dBm <= RSSI < -49dBm * + * - 62 - -49dBm <= RSSI < -48dBm * + * - 63 - RSSI >= -48dBm * + * - 99 - Unknown or undetectable * + * This field is only sent during server communication. * + * Status: Deprecated [06.00 - 06.xx.xx] */ uint32 signal = 7; - /* Hash of the current configuration. Hash value changes each time a device receives a new configuration */ - uint32 hash = 9; - - /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ + /* Cloud token. * + * Can be empty, set to any user-defined value, or hold device IMEI. * + * String up to 36 characters. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ string cloud_token = 16; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_rule.proto b/common/transport/coap/src/main/proto/efento/proto_rule.proto index 7473d9b52a..5d91712864 100644 --- a/common/transport/coap/src/main/proto/efento/proto_rule.proto +++ b/common/transport/coap/src/main/proto/efento/proto_rule.proto @@ -18,270 +18,287 @@ syntax = "proto3"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "ProtoRuleProtos"; -/* Encoding A: used to set absolute values in the Rules (e.g. upper and lower threshold values) */ -/* - TEMPERATURE - [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2:4000.0]. */ -/* - HUMIDITY - [% RH] - Relative humidity. Resolution 1%. Range [0:100]. */ -/* - ATMOSPHERIC_PRESSURE - [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0:2000.0]. */ -/* - DIFERENTIAL_PRESSURE - [Pa] - Pascal. Resolution 1Pa. Range [-10000:10000] */ -/* - OK/ALARM - Not applicable */ -/* - IAQ - [IAQ] - IAQ index. Resolution 1IAQ. Range [0:500]. */ -/* - FLOODING - Not applicable */ -/* - PULSE_CNT - [NB] Number of pulses. Resolution 1 pulse. Range [0:8000000]. */ -/* - ELECTRICITY_METER - [W] - Watt; Resolution 1W. Range [0:8000000]. Average power consumption in period */ -/* - WATER_METER [l/min] - Liter per minute. Resolution 1l/min. Range [0:8000000]. Average water flow in period. */ -/* - SOIL_MOISTURE - [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000:0]. Soil moisture (tension). */ -/* - CO_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon monoxide concentration. */ -/* - NO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Nitrogen dioxide concentration. */ -/* - H2S_GAS - [ppm] - Parts per million. Resolution 0.01ppm. Range [0.00:80000.00]. Hydrogen sulfide concentration. */ -/* - AMBIENT_LIGHT -[lx] - Lux. Resolution 0.1lx. Range [0.0:100000.0]. Illuminance. */ -/* - PM_1_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - PM_2_5 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - PM_10_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - NOISE_LEVEL - [dB] - Decibels. Resolution 0.1 dB. Range: [0.0:200.0]. Noise level. */ -/* - NH3_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Ammonia concentration. */ -/* - CH4_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Methane concentration. */ -/* - HIGH_PRESSURE - [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [0:200000]. Pressure. */ -/* - DISTANCE_MM - [mm] - Millimeter. Resolution 1mm. Range [0:100000]. Distance. */ -/* - WATER_METER_ACC_MINOR - [l] - Liter. Resolution 1l. Range [0:1000000]. Accumulative water meter (minor). */ -/* - WATER_METER_ACC_MAJOR - [hl] - Hectoliter. Resolution 1hl. Range [0:1000000]. Accumulative water meter (major). */ -/* - CO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide concentration. */ -/* - HUMIDITY ACCURATE - [% RH] - Relative humidity. Resolution 0.1%. Range [0.0:100.0]. */ -/* - STATIC_IAQ - [sIAQ] - Static IAQ index. Resolution 1sIAQ. Range [0:10000]. */ -/* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide equivalent. */ -/* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ -/* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. */ -/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. */ -/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.00:10000.00]. */ -/* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:1000000]. Accumulative pulse counter (minor). */ -/* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. */ -/* Accumulative pulse counter (major). */ -/* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [0:1000000]. Accumulative electricity meter (minor). */ -/* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [0:1000000]. Accumulative electricity meter (major). */ -/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:999999]. */ -/* Accumulative pulse counter wide range (minor). */ -/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [0:999999]. */ -/* Accumulative pulse counter wide range (major). */ -/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. */ -/* - OUTPUT_CONTROL - Not applicable */ - -/* Encoding R: used to set relative values in the Rules (e.g. differential threshold and hysteresis) */ -/* - TEMPERATURE - [°C] - Celsius degree. Resolution 0.1°C. Range [0.1:4273.2]. */ -/* - HUMIDITY - [% RH] - Relative humidity. Resolution 1%. Range [1:100]. */ -/* - ATMOSPHERIC_PRESSURE - [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [0.1:1999.0]. */ -/* - DIFERENTIAL_PRESSURE - [Pa] - Pascal. Resolution 1Pa. Range [1:20000] */ -/* - OK/ALARM - Not applicable */ -/* - VOC - [IAQ] - Iaq index. Resolution 1IAQ. Range [1:500]. */ -/* - FLOODING - Not applicable */ -/* - PULSE_CNT - [NB] Number of pulses. Resolution 1 pulse. Range [1:8000000]. */ -/* - ELECTRICITY_METER - [W] - Watt; Resolution 1W. Range [1:8000000]. Average power consumption in period */ -/* - WATER_METER [l/min] - Liter per minute. Resolution 1l/min. Range [1:8000000]. Average water flow in period. */ -/* - SOIL_MOISTURE - [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [1:1000]. Soil moisture (tension). */ -/* - CO_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon monoxide concentration. */ -/* - NO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Nitrogen dioxide concentration. */ -/* - H2S_GAS - [ppm] - Parts per million. Resolution 0.01ppm. Range [0.01:80000.00]. Hydrogen sulfide concentration. */ -/* - AMBIENT_LIGHT -[lx] - Lux. Resolution 0.1lx. Range [0.1:100000.0]. Illuminance. */ -/* - PM_1_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - PM_2_5 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - PM_10_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - NOISE_LEVEL - [dB] - Decibels. Resolution 0.1 dB. Range: [0.1:200.0]. Noise level. */ -/* - NH3_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Ammonia concentration. */ -/* - CH4_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Methane concentration. */ -/* - HIGH_PRESSURE - [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [1:200000]. Pressure. */ -/* - DISTANCE_MM - [mm] - Millimeter. Resolution 1mm. Range [1:100000]. Distance. */ -/* - WATER_METER_ACC_MINOR - [l] - Liter. Resolution 1l. Range [1:1000000]. Accumulative water meter (minor). */ -/* - WATER_METER_ACC_MAJOR - [hl] - Hectoliter. Resolution 1hl. Range [1:1000000]. Accumulative water meter (major). */ -/* - CO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon dioxide concentration. */ -/* - HUMIDITY ACCURATE - [% RH] - Relative humidity. Resolution 0.1%. Range [0.1:100.0]. */ -/* - STATIC_IAQ - [sIAQ] - Static IAQ index. Resolution 1sIAQ. Range [1:10000]. */ -/* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon dioxide equivalent. */ -/* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [1:100000]. Breath VOC estimate. */ -/* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.01:100.00]. */ -/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.1:100000.0]. */ -/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.01:10000.00]. */ -/* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:1000000]. Accumulative pulse counter (minor). */ -/* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [1:1000000]. */ -/* Accumulative pulse counter (major). */ -/* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [1:1000000]. Accumulative electricity meter (minor). */ -/* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [1:1000000]. Accumulative electricity meter (major). */ -/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:999999]. */ -/* Accumulative pulse counter wide range (minor). */ -/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [1:999999]. */ -/* Accumulative pulse counter wide range (major). */ -/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [0.001:8 000.000]. */ -/* - OUTPUT_CONTROL - Not applicable */ - -/* Condition to be checked by the device. If the condition is true, an action is triggered */ +/* Encoding A: used to set absolute values in rules (e.g., upper and lower thresholds): * + * - TEMPERATURE - [°C] Celsius degree. Resolution: 0.1°C. Range: [-273.2:4000.0] * + * - HUMIDITY - [% RH] Percentage (Relative humidity). Resolution: 1%. Range: [0:100] * + * - ATMOSPHERIC_PRESSURE - [hPa] Hectopascal. Resolution: 0.1hPa. Range: [1.0:2000.0] * + * - DIFERENTIAL_PRESSURE - [Pa] Pascal. Resolution: 1Pa. Range: [-10000:10000] * + * - OK_ALARM - Not applicable * + * - IAQ - [IAQ] IAQ index. Resolution: 1IAQ. Range: [0:500] * + * - FLOODING - Not applicable * + * - PULSE_CNT - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:8000000]. Period number of pulses * + * - ELECTRICITY_METER - [W] Watt. Resolution: 1W. Range: [0:8000000]. Period average power consumption * + * - WATER_METER - [l/min] Liter per minute. Resolution: 1l/min. Range: [0:8000000]. Period average water * + * - SOIL_MOISTURE - [kPa] Kilopascal. Resolution: 1kPa. Range: [-1000:0] * + * - CO_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - NO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - H2S_GAS - [ppm] Parts per million. Resolution: 0.01ppm. Range: [0.00:80000.00] * + * - AMBIENT_LIGHT - [lx] Lux. Resolution: 0.1lx. Range: [0.0:100000.0] * + * - PM_1_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - PM_2_5 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - PM_10_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - NOISE_LEVEL - [dB] Decibel. Resolution: 0.1 dB. Range: [0.0:200.0] * + * - NH3_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - CH4_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - HIGH_PRESSURE - [kPa] Kilopascal (100kPa = 1bar). Resolution: 1kPa. Range: [0:200000] * + * - DISTANCE_MM - [mm] Millimeter. Resolution: 1mm. Range: [0:100000] * + * - WATER_METER_ACC_MINOR - [l] Liter. Resolution: 1l. Range: [0:99] * + * - WATER_METER_ACC_MAJOR - [hl] Hectoliter. Resolution: 1hl. Range: [0:999999] * + * - CO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - HUMIDITY_ACCURATE - [% RH] Percentage (Relative humidity). Resolution: 0.1%. Range: [0.0:100.0] * + * - STATIC_IAQ - [sIAQ] Static IAQ index. Resolution: 1sIAQ. Range: [0:10000] * + * - CO2_EQUIVALENT - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - BREATH_VOC - [ppm] Parts per million. Resolution: 1ppm. Range: [0:100000] * + * - CELLULAR_GATEWAY - Not applicable * + * - PERCENTAGE - [%] Percentage. Resolution: 0.01%. Range: [0.00:100.00] * + * - VOLTAGE - [mV] Millivolt. Resolution: 0.1mV. Range: [0.0:100000.0] * + * - CURRENT - [mA] Milliampere. Resolution: 0.01mA. Range: [0.00:10000.00] * + * - PULSE_CNT_ACC_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:999] * + * - PULSE_CNT_ACC_MAJOR - [kNB] Number of kilopulses. Resolution: 1 kilopulse. Range: [0:999999] * + * - ELEC_METER_ACC_MINOR - [Wh] Watt-hour. Resolution: 1Wh. Range: [0:999] * + * - ELEC_METER_ACC_MAJOR - [kWh] Kilowatt-hour. Resolution: 1kWh. Range: [0:999999] * + * - PULSE_CNT_ACC_WIDE_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:999999] * + * - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] Number of megapulses. Resolution: 1 megapulse. Range: [0:999999] * + * - CURRENT_PRECISE - [mA] Milliampere. Resolution: 0.001mA. Range: [-4000.000:4000.000] * + * - OUTPUT_CONTROL - Not applicable * + * - RESISTANCE - [Ω] Ohm. Resolution: 1Ω. Range: [0:1000000] */ + +/* Encoding R: used to set relative values in rules (e.g., differential threshold and hysteresis): * + * - TEMPERATURE - [°C] Celsius degree. Resolution: 0.1°C. Range: [0.1:4273.2] * + * - HUMIDITY - [% RH] Percentage (Relative humidity). Resolution: 1%. Range: [1:100] * + * - ATMOSPHERIC_PRESSURE - [hPa] Hectopascal. Resolution: 0.1hPa. Range: [0.1:1999.0] * + * - DIFERENTIAL_PRESSURE - [Pa] Pascal. Resolution: 1Pa. Range: [1:20000] * + * - OK_ALARM - Not applicable * + * - IAQ - [IAQ] IAQ index. Resolution: 1IAQ. Range: [1:500] * + * - FLOODING - Not applicable * + * - PULSE_CNT - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:8000000]. Period number of pulses * + * - ELECTRICITY_METER - [W] Watt. Resolution: 1W. Range: [1:8000000]. Period average power consumption * + * - WATER_METER - [l/min] Liter per minute. Resolution: 1l/min. Range: [1:8000000]. Period average water * + * - SOIL_MOISTURE - [kPa] Kilopascal. Resolution: 1kPa. Range: [1:1000] * + * - CO_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - NO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - H2S_GAS - [ppm] Parts per million. Resolution: 0.01ppm. Range: [0.01:80000.00] * + * - AMBIENT_LIGHT - [lx] Lux. Resolution: 0.1lx. Range: [0.1:100000.0] * + * - PM_1_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - PM_2_5 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - PM_10_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - NOISE_LEVEL - [dB] Decibel. Resolution: 0.1 dB. Range: [0.1:200.0] * + * - NH3_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - CH4_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - HIGH_PRESSURE - [kPa] Kilopascal (100kPa = 1bar). Resolution: 1kPa. Range: [1:200000] * + * - DISTANCE_MM - [mm] Millimeter. Resolution: 1mm. Range: [1:100000] * + * - WATER_METER_ACC_MINOR - [l] Liter. Resolution: 1l. Range: [1:99] * + * - WATER_METER_ACC_MAJOR - [hl] Hectoliter. Resolution: 1hl. Range: [1:999999] * + * - CO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - HUMIDITY_ACCURATE - [% RH] Percentage (Relative humidity). Resolution: 0.1%. Range: [0.1:100.0] * + * - STATIC_IAQ - [sIAQ] Static IAQ index. Resolution: 1sIAQ. Range: [1:10000] * + * - CO2_EQUIVALENT - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - BREATH_VOC - [ppm] Parts per million. Resolution: 1ppm. Range: [1:100000] * + * - CELLULAR_GATEWAY - Not applicable * + * - PERCENTAGE - [%] Percentage. Resolution: 0.01%. Range: [0.01:100.00] * + * - VOLTAGE - [mV] Millivolt. Resolution: 0.1mV. Range: [0.1:100000.0] * + * - CURRENT - [mA] Milliampere. Resolution: 0.01mA. Range: [0.01:10000.00] * + * - PULSE_CNT_ACC_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:999] * + * - PULSE_CNT_ACC_MAJOR - [kNB] Number of kilopulses. Resolution: 1 kilopulse. Range: [1:999999] * + * - ELEC_METER_ACC_MINOR - [Wh] Watt-hour. Resolution: 1Wh. Range: [1:999] * + * - ELEC_METER_ACC_MAJOR - [kWh] Kilowatt-hour. Resolution: 1kWh. Range: [1:999999] * + * - PULSE_CNT_ACC_WIDE_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:999999] * + * - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] Number of megapulses. Resolution: 1 megapulse. Range: [1:999999] * + * - CURRENT_PRECISE - [mA] Milliampere. Resolution: 0.001mA. Range: [0.001:8000.000] * + * - OUTPUT_CONTROL - Not applicable * + * - RESISTANCE - [Ω] Ohm. Resolution: 1Ω. Range: [1:1000000] */ + +/* Condition to be checked by the device. If the condition is true, an action is triggered. */ enum Condition { - /* Invalid value */ + /* Invalid value. */ CONDITION_UNSPECIFIED = 0; - /* Threshold function for given rule_id is disabled */ + /* The rule is disabled. */ CONDITION_DISABLED = 1; - /* Upper threshold. Continuous sensors only. If the measurement (or average from a few measurements) is over the threshold, */ - /* an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding A" format. Must match channel type */ - /* parameter[1] - Hysteresis value in "Encoding R" format. Must much channel type. Set to "0" to disable */ - /* parameter[2] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[3] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample above threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[4] - Type of measurement (as described in MeasurementType). */ + /* Upper threshold. 'Continuous' sensors only. * + * If the measurement (or average from a few measurements) is over the threshold, an action is triggered. * + * parameter[0] - Threshold value in "Encoding A" format. Must match channel type. * + * parameter[1] - Hysteresis value in "Encoding R" format. Must match channel type. Set to "0" to disable. * + * parameter[2] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[3] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample above threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[4] - Type of measurement (as described in MeasurementType). */ CONDITION_HIGH_THRESHOLD = 2; - /* Lower threshold. Continuous sensors only. If the measurement (or average from a few measurements) is below the threshold, */ - /* an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding A" format. Must match channel type */ - /* parameter[1] - Hysteresis value in "Encoding R" format. Must much channel type. Set to "0" to disable */ - /* parameter[2] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[3] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample below threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[4] - Type of measurement (as described in MeasurementType). */ + /* Lower threshold. 'Continuous' sensors only. * + * If the measurement (or average from a few measurements) is below the threshold, an action is triggered. * + * parameter[0] - Threshold value in "Encoding A" format. Must match channel type. * + * parameter[1] - Hysteresis value in "Encoding R" format. Must match channel type. Set to "0" to disable. * + * parameter[2] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[3] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample below threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[4] - Type of measurement (as described in MeasurementType). */ CONDITION_LOW_THRESHOLD = 3; - /* Differential threshold. Continuous sensors only. If the absolute value of the difference between the last value sent to */ - /* the server and the measurement value (or average from a few measurements) is greater or equal to the value of */ - /* the threshold set, an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding R" format. Must match channel type */ - /* parameter[1] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[2] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample exceeding threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[3] - Type of measurement (as described in MeasurementType). */ + /* Differential threshold. 'Continuous' sensors only. * + * If the absolute value of the difference between the last value sent to the server and the measurement value * + * (or average from a few measurements) is greater or equal to the value of the threshold set, an action is triggered. * + * parameter[0] - Threshold value in "Encoding R" format. Must match channel type. * + * parameter[1] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[2] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample exceeding threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[3] - Type of measurement (as described in MeasurementType). */ CONDITION_DIFF_THRESHOLD = 4; - /* Change of binary sensor's state. Binary sensors only. Each change of the binary's sensor state will trigger an action. */ + /* Change of binary sensor's state. 'Binary' sensors only. * + * Each change of the binary sensor's state will trigger an action. */ CONDITION_BINARY_CHANGE_STATE = 5; - /* Logic operator. Used for combining multiple rules into more complex conditions. If the logic condition specified by */ - /* parameters (logic operator and selected rules) is met, an action is triggered. */ - /* parameter[0] - Logic operator (as described in LogicOperation). */ - /* parameter[1] - Rule selector (bit mask). Specifies which rules should be taken into account while determining */ - /* rules outcome. */ - /* parameter[2] - Rule negation (bit mask). Specifies which of chosen in parameter[1] rules should be negated */ - /* before determining rules outcome. */ - /* parameter[3] - Rule action delay [s]. Specifies time delay between the rule activation and rule action being triggered. */ - /* Range: [0:864000]. */ - /* parameter[4] - Rule return delay [s]. Specifies time delay between the rule deactivation and rule action being triggered. */ - /* Range: [0:864001]. Max parameter value disables action triggering on rule deactivation. */ + /* Logic operator. Used for combining multiple rules into more complex conditions. * + * If the logic condition specified by parameters (logic operator and selected rules) is met, an action is triggered. * + * parameter[0] - Logic operator (as described in LogicOperator). * + * parameter[1] - Rule selector (bitmask). Specifies which rules should be taken into account while determining * + * rules outcome. * + * parameter[2] - Rule negation (bitmask). Specifies which of chosen in parameter[1] rules should be negated * + * before determining rules outcome. * + * parameter[3] - Rule action delay [s]. Specifies time delay between the rule activation and rule action being triggered. * + * Range: [0:864000]. * + * parameter[4] - Rule return delay [s]. Specifies time delay between the rule deactivation and rule action being triggered. * + * Range: [0:864001]. Max parameter value disables action triggering on rule deactivation. */ CONDITION_LOGIC_OPERATOR = 6; - /* On measurement. Continous sensors only. The basic function is to trigger communication after measurement if at least 60s */ - /* have passed since the last one. Transmission may occur every x measurement. Optionally dependency on the other rule can */ - /* be configured, then, when all conditions are met, transmission is triggered. */ - /* parameter[0] - Send every n measurement. This parameter specifies every which measurement transmission will be triggered */ - /* if all other conditions are met. Range: [1:500]. If parameter[0] equals 1, transmission will occur after */ - /* every measurement. */ - /* parameter[1] - Optional. Rule selector (bit mask). Specifies which rule should be taken into account while determining */ - /* the measurement rule outcome. */ - /* parameter[2] - Optional. Rule negation (bit mask). Specifies which of chosen in parameter[1] rule should be negated */ - /* before determining the measurement rule outcome. */ + /* On measurement. 'Continous' sensors only. + * The basic function is to trigger communication after measurement if at least 60s have passed since the last one. * + * Transmission may occur every n-th measurement. Optionally dependency on the other rule can be configured, then, when all * + * conditions are met, transmission is triggered. * + * parameter[0] - Send every n-th measurement. This parameter specifies every which measurement transmission will be * + * triggered if all other conditions are met. Range: [1:500]. If parameter[0] equals 1, transmission will * + * occur after every measurement. * + * parameter[1] - Optional. Rule selector (bitmask). Specifies which rule should be taken into account while determining * + * the measurement rule outcome. * + * parameter[2] - Optional. Rule negation (bitmask). Specifies which of the rules selected in parameter[1] should be negated * + * before determining the measurement rule outcome. */ CONDITION_ON_MEASUREMENT = 7; + + /* On sensor error. 'Continous' sensors only. * + * If the sensor returns an error code, an action will be triggered. * + * The rule becomes inactive when a correct measurement appears. */ + CONDITION_ON_SENSOR_ERROR = 8; } /* Logic operators to be used for determining the outcome of rules with logic operator condition. */ enum LogicOperator { - /* Invalid use */ + /* Invalid value. */ LOGIC_OPERATOR_UNSPECIFIED = 0; - /* Logic AND */ + /* Logic AND. */ LOGIC_OPERATOR_AND = 1; - /* Logic OR */ + /* Logic OR. */ LOGIC_OPERATOR_OR = 2; } -/* Action to be triggered. Currently the only possible action is to trigger the transmission. */ -/* Other actions will be available in next SW releases. */ +/* Action to be triggered. */ enum Action { - /* Invalid value */ + /* Invalid value. */ ACTION_UNSPECIFIED = 0; - /* To trigger the transmission */ + /* To trigger the transmission. */ ACTION_TRIGGER_TRANSMISSION = 1; - /* To take no action. Possible for logic operator components */ + /* To take no action. Possible for logic operator components. */ ACTION_NO_ACTION = 2; - /* To trigger the transmission with ACK */ + /* To trigger the transmission with ACK. */ ACTION_TRIGGER_TRANSMISSION_WITH_ACK = 3; - /* To change BLE advertising period mode to fast (with lower user-configured advertising interval). */ - /* Once the rule is deactived avertising period mode returns to previously configured value. */ + /* To change BLE advertising period mode to fast (with lower user-configured advertising interval). * + * Once the rule is deactived avertising period mode returns to previously configured value. */ ACTION_FAST_ADVERTISING_MODE = 4; } /* Type of a rule calendars. */ enum CalendarType { - /* Invalid value */ + /* Invalid value. */ CALENDAR_TYPE_UNSPECIFIED = 0; - /* Type for inactive calendars */ + /* Calendar is inactive. */ CALENDAR_TYPE_DISABLED = 1; - /* Week type. Enables selcted rules on specified days of the week in specified time periods. */ - /* parameter[0] - Week day mask. Bitmask of days when selected rules are enabled */ - /* - Bit 0 - Sunday */ - /* - Bit 1 - Monday */ - /* ... */ - /* - Bit 6 - Saturday */ - /* parameter[1] - 'From time' - point in time from which selected rules will be enabled (in minutes from midnight). */ - /* parameter[2] - 'To time' - point in time from which selected rules will be disabled (in minutes from midnight). */ - /* Note: if 'From time' is bigger than 'To time' there are two periods when rules are enabled - from 00:00 to 'To time' */ - /* and from 'From time' to 23:59. */ - /* parameter[3] - Timezone - desired timezone for date comparison. Encoded as number (N) of 15 minutes offsets */ - /* - example - if N = 4, then offset = 4 * 15min = 1h. I.e. timezone is UTC+1. */ + /* Week calendar. Enables selcted rules on specified days of the week in specified time periods. * + * parameter[0] - Week day mask. Bitmask of days when selected rules are enabled: * + * - Bit 0 - Sunday * + * - Bit 1 - Monday * + * ... * + * - Bit 6 - Saturday * + * parameter[1] - 'From time' - point in time from which selected rules will be enabled (in minutes from midnight). * + * parameter[2] - 'To time' - point in time from which selected rules will be disabled (in minutes from midnight). * + * parameter[3] - Timezone - desired timezone for date comparison. Encoded as number (N) of 15 minutes offsets, * + * for example: if N = 4, then offset = 4 * 15min = 1h. I.e. timezone is UTC+1. * + * Note: If 'From time' is bigger than 'To time' there are two periods when rules are enabled - from 00:00 to 'To time' * + * and from 'From time' to 23:59. */ CALENDAR_TYPE_WEEK = 2; } /* Rules calendars. Used for enabling/disabling rules based on date/time. */ -/* It is possible to configure up to 6 calendars. Each of them can affect any number of rules. */ message ProtoCalendar { - /* Bit mask of selected rules. Mask on bits [0:11] */ - /* - Bit 0 - Rule ID 0 */ - /* - Bit 1 - Rule ID 1 */ - /* ... */ - /* - Bit 11 - Rule ID 11 */ + /* Selected rules. * + * Bitmask - up to 16 rules supported (previously 12 rules [06.00 - 07.01.xx]): * + * - Bit 0 - Rule 1 * + * - Bit 1 - Rule 2 * + * ... * + * - Bit 15 - Rule 16 * + * Status: In use [06.08.00 - LATEST] */ uint32 rule_mask = 1; - /* Calendars's parameters. Described in Type. */ + /* Calendar parameters (as described in CalendarType). * + * Status: In use [06.08.00 - LATEST] */ repeated sint32 parameters = 2; - /* Calendar's type. Described in Type. */ + /* Calendar type (as described in CalendarType). * + * Status: In use [06.08.00 - LATEST] */ CalendarType type = 3; } -/* Rules used to define edge logic on the device. Rules are defined by conditions and actions: */ -/* If Condition is true, trigger Action. It is possible to configure up to 12 rules and assign them to different channels. */ -/* One rule can be assigned to any number of channels. For instance rule "If temperature is over 10 C, trigger the transmission"*/ -/* can be assigned to channels 1 and 2. No matter to how many channels a rule is assigned, it's still counted as one rule. */ +/* Rules are used to define edge logic on the device. Rules are defined by conditions and actions. * + * If 'condition' is true, trigger 'action'. One rule can be assigned to any number of channels. * + * For instance rule "If temperature is over 10 C, trigger the transmission" can be assigned to channels 1 and 2. * + * No matter to how many channels a rule is assigned, it's still counted as one rule. */ message ProtoRule { - /* Channels to which the rule is assigned. One rule can be assigned to multiple channels as long as those are of the same type*/ - /* Bit mask on bits [0:5]. E.g. To assign the rule for channel 1: "000001", to assign rule to channels 2 and 4: "001010" */ + /* Channels to which the rule is assigned. * + * One rule can be assigned to multiple channels as long as those are of the same type. * + * Bitmask: * + * - Bit 0 - Channel 1 * + * - Bit 1 - Channel 2 * + * ... * + * - Bit 5 - Channel 6 * + * Status: In use [06.00 - LATEST] */ uint32 channel_mask = 1; - /* Rule's condition (as described in Condition). */ + /* Rule condition (as described in Condition). * + * Status: In use [06.00 - LATEST] */ Condition condition = 2; - /* Condition's parameters (as described in Condition). For binary sensors there are no parameters */ + /* Condition parameters (as described in Condition). * + * For 'Binary' sensors there are no parameters. * + * Status: In use [06.00 - LATEST] */ repeated sint32 parameters = 3; - /* Action to be triggered. */ + /* Action to be triggered. * + * Status: In use [06.00 - LATEST] */ Action action = 4; } \ No newline at end of file diff --git a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java index eb0a764f5e..f6eaa9976f 100644 --- a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java +++ b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java @@ -16,23 +16,32 @@ package org.thingsboard.server.transport.coap.efento; import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.thingsboard.server.gen.transport.coap.ConfigProtos; +import org.thingsboard.server.gen.transport.coap.ConfigTypesProtos; +import org.thingsboard.server.gen.transport.coap.DeviceInfoProtos; import org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType; import org.thingsboard.server.gen.transport.coap.MeasurementsProtos; import org.thingsboard.server.gen.transport.coap.MeasurementsProtos.ProtoMeasurements; +import org.thingsboard.server.gen.transport.coap.ProtoRuleProtos; import org.thingsboard.server.transport.coap.CoapTransportContext; import org.thingsboard.server.transport.coap.efento.utils.CoapEfentoUtils; import java.nio.ByteBuffer; +import java.text.SimpleDateFormat; import java.time.Instant; import java.util.Arrays; +import java.util.Comparator; +import java.util.Date; import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -85,7 +94,7 @@ class CoapEfentoTransportResourceTest { void checkContinuousSensorWithSomeMeasurements() { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(5) @@ -93,7 +102,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() .setType(MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) .setTimestamp(Math.toIntExact(tsInSec)) @@ -114,7 +123,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 180 * 5) * 1000); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("temperature_1").getAsDouble()).isEqualTo(22.4); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("humidity_2").getAsDouble()).isEqualTo(30); - checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 5, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 5); } @ParameterizedTest @@ -122,7 +131,7 @@ class CoapEfentoTransportResourceTest { void checkContinuousSensor(MeasurementType measurementType, List sampleOffsets, String property, double expectedValue) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -130,7 +139,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() .setType(measurementType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -142,7 +151,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsDouble()).isEqualTo(expectedValue); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkContinuousSensor() { @@ -176,7 +185,7 @@ class CoapEfentoTransportResourceTest { String totalPropertyName, double expectedTotalValue) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -184,7 +193,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(Arrays.asList(MeasurementsProtos.ProtoChannel.newBuilder() .setType(majorType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -200,7 +209,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(totalPropertyName + "_2").getAsDouble()).isEqualTo(expectedTotalValue); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkPulseCounterSensors() { @@ -220,7 +229,7 @@ class CoapEfentoTransportResourceTest { void checkBinarySensor() { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -228,7 +237,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() .setType(MEASUREMENT_TYPE_OK_ALARM) .setTimestamp(Math.toIntExact(tsInSec)) @@ -239,7 +248,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get("ok_alarm_1").getAsString()).isEqualTo("ALARM"); - checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 14, true); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 14); } @ParameterizedTest @@ -247,7 +256,7 @@ class CoapEfentoTransportResourceTest { void checkBinarySensorWhenValueIsVarying(MeasurementType measurementType, String property, String expectedValueWhenOffsetNotOk, String expectedValueWhenOffsetOk) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(1) @@ -255,7 +264,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() .setType(measurementType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -268,7 +277,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetNotOk); assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 9) * 1000); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetOk); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, true); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkBinarySensorWhenValueIsVarying() { @@ -282,7 +291,7 @@ class CoapEfentoTransportResourceTest { @Test void checkExceptionWhenChannelsListIsEmpty() { ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(1) @@ -290,7 +299,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .build(); UUID sessionId = UUID.randomUUID(); @@ -299,29 +308,587 @@ class CoapEfentoTransportResourceTest { .hasMessage("[" + sessionId + "]: Failed to get Efento measurements, reason: channels list is empty!"); } + // ------------------------------------------------------------------------- + // ProtoDeviceInfo parsing tests + // ------------------------------------------------------------------------- + @Test - void checkExceptionWhenValuesMapIsEmpty() { - long tsInSec = Instant.now().getEpochSecond(); - ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) - .setCloudToken("test_token") - .setMeasurementPeriodBase(180) - .setMeasurementPeriodFactor(1) - .setBatteryStatus(true) - .setSignal(0) - .setNextTransmissionAt(1000) - .setTransferReason(0) - .setHash(0) - .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() - .setType(MEASUREMENT_TYPE_TEMPERATURE) - .setTimestamp(Math.toIntExact(tsInSec)) + void getEfentoDeviceInfo_parsesSwVersion() { + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setSwVersion(1546) // ver 06.10 => 0x060A => 1546 + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + + assertThat(result.getValues().getAsJsonObject().get("sw_version").getAsInt()).isEqualTo(1546); + } + + @Test + void getEfentoDeviceInfo_parsesAllMemoryStatistics() { + // proto uint32 maps to Java int; the "undefined" sentinel is 0xFFFFFFFF = -1 in signed int + int undefinedTs = -1; + int knownTs = 1_700_000_000; // arbitrary known Unix timestamp fitting in uint32 + // clearMemoryStatistics() is needed because minimalDeviceInfo() pre-populates 22 zeros; + // addAllMemoryStatistics() appends in proto, so without clear the test values land at indices 22-43 + DeviceInfoProtos.ProtoDeviceInfo.Builder builder = minimalDeviceInfo().clearMemoryStatistics(); + builder.addAllMemoryStatistics(List.of( + 0, // [0] nv_storage_status: 0 = no errors + knownTs, // [1] timestamp_of_the_end_of_collecting_statistics + 1048576, // [2] capacity_of_memory_in_bytes + 512000, // [3] used_space_in_bytes + 1024, // [4] size_of_invalid_packets_in_bytes + 256, // [5] size_of_corrupted_packets_in_bytes + 100, // [6] number_of_valid_packets + 10, // [7] number_of_invalid_packets + 2, // [8] number_of_corrupted_packets + 500, // [9] number_of_all_samples_for_channel_1 + 400, // [10] number_of_all_samples_for_channel_2 + 300, // [11] number_of_all_samples_for_channel_3 + 200, // [12] number_of_all_samples_for_channel_4 + 100, // [13] number_of_all_samples_for_channel_5 + 50, // [14] number_of_all_samples_for_channel_6 + undefinedTs, // [15] timestamp_of_the_first_binary_measurement (undefined) + undefinedTs, // [16] timestamp_of_the_last_binary_measurement (undefined) + undefinedTs, // [17] timestamp_of_the_first_binary_measurement_sent (undefined) + knownTs, // [18] timestamp_of_the_first_continuous_measurement + knownTs, // [19] timestamp_of_the_last_continuous_measurement + knownTs, // [20] timestamp_of_the_last_continuous_measurement_sent + 42 // [21] nvm_write_counter + )); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(builder.build()); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("nv_storage_status").getAsLong()).isEqualTo(0); + assertThat(json.get("timestamp_of_the_end_of_collecting_statistics").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("capacity_of_memory_in_bytes").getAsLong()).isEqualTo(1048576L); + assertThat(json.get("used_space_in_bytes").getAsLong()).isEqualTo(512000L); + assertThat(json.get("size_of_invalid_packets_in_bytes").getAsLong()).isEqualTo(1024L); + assertThat(json.get("size_of_corrupted_packets_in_bytes").getAsLong()).isEqualTo(256L); + assertThat(json.get("number_of_valid_packets").getAsLong()).isEqualTo(100L); + assertThat(json.get("number_of_invalid_packets").getAsLong()).isEqualTo(10L); + assertThat(json.get("number_of_corrupted_packets").getAsLong()).isEqualTo(2L); + assertThat(json.get("number_of_all_samples_for_channel_1").getAsLong()).isEqualTo(500L); + assertThat(json.get("number_of_all_samples_for_channel_2").getAsLong()).isEqualTo(400L); + assertThat(json.get("number_of_all_samples_for_channel_3").getAsLong()).isEqualTo(300L); + assertThat(json.get("number_of_all_samples_for_channel_4").getAsLong()).isEqualTo(200L); + assertThat(json.get("number_of_all_samples_for_channel_5").getAsLong()).isEqualTo(100L); + assertThat(json.get("number_of_all_samples_for_channel_6").getAsLong()).isEqualTo(50L); + assertThat(json.get("timestamp_of_the_first_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement_sent").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_continuous_measurement").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("timestamp_of_the_last_continuous_measurement").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("timestamp_of_the_last_continuous_measurement_sent").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("nvm_write_counter").getAsLong()).isEqualTo(42L); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfo() { + // 34 modem parameters (indices 0-33) as defined in proto_device_info.proto + List params = List.of( + 0, // [0] sc_EARNFCN_offset + 1000, // [1] sc_EARFCN + 42, // [2] sc_PCI + 123456, // [3] sc_Cell_id + -90, // [4] sc_RSRP + -10, // [5] sc_RSRQ + -80, // [6] sc_RSSI + 15, // [7] sc_SINR + 3, // [8] sc_Band + 1234, // [9] sc_TAC + 1, // [10] sc_ECL + -30, // [11] sc_TX_PWR + 2, // [12] op_mode + 999, // [13] nc_EARFCN + 1, // [14] nc_EARNFCN_offset + 100, // [15] nc_PCI + -95, // [16] nc_RSRP + 5, // [17] RLC_UL_BLER + 3, // [18] RLC_DL_BLER + 4, // [19] MAC_UL_BLER + 2, // [20] MAC_DL_BLER + 50000,// [21] MAC_UL_TOTAL_BYTES + 60000,// [22] MAC_DL_TOTAL_BYTES + 200, // [23] MAC_UL_total_HARQ_Tx + 150, // [24] MAC_DL_total_HARQ_Tx + 10, // [25] MAC_UL_HARQ_re_Tx + 8, // [26] MAC_DL_HARQ_re_Tx + 1000, // [27] RLC_UL_tput + 1200, // [28] RLC_DL_tput + 900, // [29] MAC_UL_tput + 1100, // [30] MAC_DL_tput + 5000, // [31] sleep_duration + 300, // [32] rx_time + 100 // [33] tx_time + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC66) + .addAllParameters(params) .build()) .build(); - UUID sessionId = UUID.randomUUID(); - assertThatThrownBy(() -> coapEfentoTransportResource.getEfentoMeasurements(measurements, sessionId)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_BC66"); + assertThat(json.get("sc_EARNFCN_offset").getAsInt()).isEqualTo(0); + assertThat(json.get("sc_EARFCN").getAsInt()).isEqualTo(1000); + assertThat(json.get("sc_PCI").getAsInt()).isEqualTo(42); + assertThat(json.get("sc_Cell_id").getAsInt()).isEqualTo(123456); + assertThat(json.get("sc_RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("sc_RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("sc_RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("sc_SINR").getAsInt()).isEqualTo(15); + assertThat(json.get("sc_Band").getAsInt()).isEqualTo(3); + assertThat(json.get("sc_TAC").getAsInt()).isEqualTo(1234); + assertThat(json.get("sc_ECL").getAsInt()).isEqualTo(1); + assertThat(json.get("sc_TX_PWR").getAsInt()).isEqualTo(-30); + assertThat(json.get("op_mode").getAsInt()).isEqualTo(2); + assertThat(json.get("nc_EARFCN").getAsInt()).isEqualTo(999); + assertThat(json.get("nc_EARNFCN_offset").getAsInt()).isEqualTo(1); + assertThat(json.get("nc_PCI").getAsInt()).isEqualTo(100); + assertThat(json.get("nc_RSRP").getAsInt()).isEqualTo(-95); + assertThat(json.get("RLC_UL_BLER").getAsInt()).isEqualTo(5); + assertThat(json.get("RLC_DL_BLER").getAsInt()).isEqualTo(3); + assertThat(json.get("MAC_UL_BLER").getAsInt()).isEqualTo(4); + assertThat(json.get("MAC_DL_BLER").getAsInt()).isEqualTo(2); + assertThat(json.get("MAC_UL_TOTAL_BYTES").getAsInt()).isEqualTo(50000); + assertThat(json.get("MAC_DL_TOTAL_BYTES").getAsInt()).isEqualTo(60000); + assertThat(json.get("MAC_UL_total_HARQ_Tx").getAsInt()).isEqualTo(200); + assertThat(json.get("MAC_DL_total_HARQ_Tx").getAsInt()).isEqualTo(150); + assertThat(json.get("MAC_UL_HARQ_re_Tx").getAsInt()).isEqualTo(10); + assertThat(json.get("MAC_DL_HARQ_re_Tx").getAsInt()).isEqualTo(8); + assertThat(json.get("RLC_UL_tput").getAsInt()).isEqualTo(1000); + assertThat(json.get("RLC_DL_tput").getAsInt()).isEqualTo(1200); + assertThat(json.get("MAC_UL_tput").getAsInt()).isEqualTo(900); + assertThat(json.get("MAC_DL_tput").getAsInt()).isEqualTo(1100); + assertThat(json.get("sleep_duration").getAsInt()).isEqualTo(5000); + assertThat(json.get("rx_time").getAsInt()).isEqualTo(300); + assertThat(json.get("tx_time").getAsInt()).isEqualTo(100); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfoBC660() { + // 22 modem parameters for MODEM_TYPE_BC660 as defined in proto_device_info.proto + List params = List.of( + 1000, // [0] sc_EARFCN + 5, // [1] sc_EARNFCN_offset + 42, // [2] sc_PCI + 123456, // [3] sc_Cell_id + -90, // [4] sc_RSRP + -10, // [5] sc_RSRQ + -80, // [6] sc_RSSI + 15, // [7] sc_SINR + 3, // [8] sc_Band + 1234, // [9] sc_TAC + 1, // [10] sc_ECL + -30, // [11] sc_TX_PWR + 2, // [12] op_mode + 999, // [13] nc_EARFCN + 100, // [14] nc_PCI + -95, // [15] nc_RSRP + -12, // [16] nc_RSRQ + 5000, // [17] sleep_duration + 300, // [18] rx_time + 100, // [19] tx_time + 1, // [20] PLMN_state + 26201 // [21] select_PLMN + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC660) + .addAllParameters(params) + .setSimCardIdentification("89001012012341234120") + .setFirmwareVersion(DeviceInfoProtos.ModemFirmwareVersion.MODEM_FIRMWARE_VERSION_BC660_V2) + .setModemIdentification("123456789012345") + .addAllModemStatistics(List.of(10, 3600, 7200, 600)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_BC660"); + assertThat(json.get("sim_card_identification").getAsString()).isEqualTo("89001012012341234120"); + assertThat(json.get("firmware_version").getAsString()).isEqualTo("MODEM_FIRMWARE_VERSION_BC660_V2"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("123456789012345"); + assertThat(json.get("modem_transmissions_count").getAsInt()).isEqualTo(10); + assertThat(json.get("modem_time_since_last_devinfo").getAsInt()).isEqualTo(3600); + assertThat(json.get("modem_total_psm_time").getAsInt()).isEqualTo(7200); + assertThat(json.get("modem_total_active_time").getAsInt()).isEqualTo(600); + assertThat(json.get("sc_EARFCN").getAsInt()).isEqualTo(1000); + assertThat(json.get("sc_EARNFCN_offset").getAsInt()).isEqualTo(5); + assertThat(json.get("sc_PCI").getAsInt()).isEqualTo(42); + assertThat(json.get("sc_Cell_id").getAsInt()).isEqualTo(123456); + assertThat(json.get("sc_RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("sc_RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("sc_RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("sc_SINR").getAsInt()).isEqualTo(15); + assertThat(json.get("sc_Band").getAsInt()).isEqualTo(3); + assertThat(json.get("sc_TAC").getAsInt()).isEqualTo(1234); + assertThat(json.get("sc_ECL").getAsInt()).isEqualTo(1); + assertThat(json.get("sc_TX_PWR").getAsInt()).isEqualTo(-30); + assertThat(json.get("op_mode").getAsInt()).isEqualTo(2); + assertThat(json.get("nc_EARFCN").getAsInt()).isEqualTo(999); + assertThat(json.get("nc_PCI").getAsInt()).isEqualTo(100); + assertThat(json.get("nc_RSRP").getAsInt()).isEqualTo(-95); + assertThat(json.get("nc_RSRQ").getAsInt()).isEqualTo(-12); + assertThat(json.get("sleep_duration").getAsInt()).isEqualTo(5000); + assertThat(json.get("rx_time").getAsInt()).isEqualTo(300); + assertThat(json.get("tx_time").getAsInt()).isEqualTo(100); + assertThat(json.get("PLMN_state").getAsInt()).isEqualTo(1); + assertThat(json.get("select_PLMN").getAsInt()).isEqualTo(26201); + // BC66-specific fields must not be present + assertThat(json.has("nc_EARNFCN_offset")).isFalse(); + assertThat(json.has("RLC_UL_BLER")).isFalse(); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfoSharedModem() { + // 4 modem parameters for MODEM_TYPE_SHARED_MODEM as defined in proto_device_info.proto + List params = List.of( + -90, // [0] RSRP + -10, // [1] RSRQ + -80, // [2] RSSI + 15 // [3] SINR + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_SHARED_MODEM) + .addAllParameters(params) + .setModemIdentification("SN-ABCDEF") + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_SHARED_MODEM"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("SN-ABCDEF"); + assertThat(json.get("RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("SINR").getAsInt()).isEqualTo(15); + // BC66/BC660-specific fields must not be present + assertThat(json.has("sc_EARFCN")).isFalse(); + assertThat(json.has("sc_EARNFCN_offset")).isFalse(); + } + + @Test + void getEfentoDeviceInfo_parsesNewModemFields() { + // New ProtoModem fields: sim_card_identification, firmware_version, modem_identification, modem_statistics + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC66) + .addAllParameters(java.util.Collections.nCopies(34, 0)) + .setSimCardIdentification("89012345678901234567") + .setFirmwareVersion(DeviceInfoProtos.ModemFirmwareVersion.MODEM_FIRMWARE_VERSION_READING_ERROR) + .setModemIdentification("352519100417272") + .addAllModemStatistics(List.of(5, 1800, 3600, 120)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("sim_card_identification").getAsString()).isEqualTo("89012345678901234567"); + assertThat(json.get("firmware_version").getAsString()).isEqualTo("MODEM_FIRMWARE_VERSION_READING_ERROR"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("352519100417272"); + assertThat(json.get("modem_transmissions_count").getAsInt()).isEqualTo(5); + assertThat(json.get("modem_time_since_last_devinfo").getAsInt()).isEqualTo(1800); + assertThat(json.get("modem_total_psm_time").getAsInt()).isEqualTo(3600); + assertThat(json.get("modem_total_active_time").getAsInt()).isEqualTo(120); + } + + @Test + void getEfentoDeviceInfo_parsesRuntimeInfo() { + long batteryResetTs = 1_700_000_000L; + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setRuntimeInfo(DeviceInfoProtos.ProtoRuntime.newBuilder() + .setUpTime(3600) + .addAllMessageCounters(List.of(10, 5, 9)) + .setMcuTemperature(25) + .setBatteryVoltage(3200) + .setMinBatteryMcuTemperature(20) + .setBatteryResetTimestamp((int) batteryResetTs) + .setMaxMcuTemperature(40) + .setMinMcuTemperature(10) + .addAllRuntimeErrors(List.of(0, 1)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("up_time").getAsLong()).isEqualTo(3600); + assertThat(json.get("mcu_temp").getAsInt()).isEqualTo(25); + assertThat(json.get("min_battery_voltage").getAsLong()).isEqualTo(3200); + assertThat(json.get("min_battery_mcu_temp").getAsInt()).isEqualTo(20); + assertThat(json.get("battery_reset_timestamp").getAsString()).isEqualTo(formatDate(batteryResetTs)); + assertThat(json.get("max_mcu_temp").getAsInt()).isEqualTo(40); + assertThat(json.get("min_mcu_temp").getAsInt()).isEqualTo(10); + assertThat(json.get("counter_of_confirmable_messages_attempts").getAsInt()).isEqualTo(10); + assertThat(json.get("counter_of_non_confirmable_messages_attempts").getAsInt()).isEqualTo(5); + assertThat(json.get("counter_of_succeeded_messages").getAsInt()).isEqualTo(9); + assertThat(json.get("runtime_errors").getAsInt()).isEqualTo(2); // count of errors, not values + } + + @Test + void getEfentoDeviceInfo_undefinedTimestampRenderedAsUndefinedString() { + // -1 as signed int == 0xFFFFFFFF == uint32 max (4294967295), the "Undefined" sentinel + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo().clearMemoryStatistics() + .addAllMemoryStatistics(List.of( + 0, -1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + -1, -1, -1, + -1, -1, -1, + 0)) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("timestamp_of_the_end_of_collecting_statistics").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement_sent").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_continuous_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_continuous_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_continuous_measurement_sent").getAsString()).isEqualTo("Undefined"); + } + + @Test + void getEfentoDeviceInfo_tsIsCurrentTimeMillis() { + long before = System.currentTimeMillis(); + CoapEfentoTransportResource.EfentoTelemetry result = + coapEfentoTransportResource.getEfentoDeviceInfo(minimalDeviceInfo().build()); + long after = System.currentTimeMillis(); + + assertThat(result.getTs()).isBetween(before, after); + } + + // ------------------------------------------------------------------------- + // ProtoConfig / getEfentoConfiguration parsing tests + // ------------------------------------------------------------------------- + + @Test + void getEfentoConfiguration_parsesServerCommunicationFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDataServerIp("18.184.24.239") + .setDataServerPort(5683) + .setUpdateServerIp("efento.update.io") + .setUpdateServerPortCoap(5684) + .setUpdateServerPortUdp(5685) + .setTransmissionInterval(300) + .setAckInterval(600) + .setSupervisionPeriod(3600) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("dataServerIp").getAsString()).isEqualTo("18.184.24.239"); + assertThat(json.get("dataServerPort").getAsLong()).isEqualTo(5683); + assertThat(json.get("updateServerIp").getAsString()).isEqualTo("efento.update.io"); + assertThat(json.get("updateServerPortCoap").getAsLong()).isEqualTo(5684); + assertThat(json.get("updateServerPortUdp").getAsLong()).isEqualTo(5685); + assertThat(json.get("transmissionInterval").getAsLong()).isEqualTo(300); + assertThat(json.get("ackInterval").getAsLong()).isEqualTo(600); + assertThat(json.get("supervisionPeriod").getAsLong()).isEqualTo(3600); + } + + @Test + void getEfentoConfiguration_parsesMeasurementPeriod() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setMeasurementPeriodBase(60) + .setMeasurementPeriodFactor(5) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("measurementPeriodBase").getAsLong()).isEqualTo(60); + assertThat(json.get("measurementPeriodFactor").getAsLong()).isEqualTo(5); + } + + @Test + void getEfentoConfiguration_parsesBooleanRequestFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDeviceInfoRequest(true) + .setUpdateSoftwareRequest(true) + .setConfigurationRequest(true) + .setAcceptWithoutTestingRequest(true) + .setResetMemoryRequest(true) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("deviceInfoRequest").getAsBoolean()).isTrue(); + assertThat(json.get("updateSoftwareRequest").getAsBoolean()).isTrue(); + assertThat(json.get("configurationRequest").getAsBoolean()).isTrue(); + assertThat(json.get("acceptWithoutTestingRequest").getAsBoolean()).isTrue(); + assertThat(json.get("resetMemoryRequest").getAsBoolean()).isTrue(); + } + + @Test + void getEfentoConfiguration_parsesModemAndNetworkFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setApn("internet.example.com") + .setApnUsername("user") + .setApnPassword("pass") + .setPlmnSelection(26001) + .setModemBandsMask(2084) // bands 3, 8, 20 + .setNetworkTroubleshooting(2) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("apn").getAsString()).isEqualTo("internet.example.com"); + assertThat(json.get("apnUsername").getAsString()).isEqualTo("user"); + assertThat(json.get("apnPassword").getAsString()).isEqualTo("pass"); + assertThat(json.get("plmnSelection").getAsLong()).isEqualTo(26001); + assertThat(json.get("modemBandsMask").getAsLong()).isEqualTo(2084); + assertThat(json.get("networkTroubleshooting").getAsLong()).isEqualTo(2); + } + + @Test + void getEfentoConfiguration_parsesCloudTokenFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setCloudToken("my-device-token") + .setCloudTokenConfig(1) + .setCloudTokenCoapOption(65000) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("cloudToken").getAsString()).isEqualTo("my-device-token"); + assertThat(json.get("cloudTokenConfig").getAsLong()).isEqualTo(1); + assertThat(json.get("cloudTokenCoapOption").getAsLong()).isEqualTo(65000); + } + + @Test + void getEfentoConfiguration_parsesEndpointFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDataEndpoint("/m") + .setConfigurationEndpoint("/c") + .setDeviceInfoEndpoint("/i") + .setTimeEndpoint("/t") + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("dataEndpoint").getAsString()).isEqualTo("/m"); + assertThat(json.get("configurationEndpoint").getAsString()).isEqualTo("/c"); + assertThat(json.get("deviceInfoEndpoint").getAsString()).isEqualTo("/i"); + assertThat(json.get("timeEndpoint").getAsString()).isEqualTo("/t"); + } + + @Test + void getEfentoConfiguration_parsesBleAdvertisingPeriod() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setBleAdvertisingPeriod(ConfigTypesProtos.ProtoBleAdvertisingPeriod.newBuilder() + .setMode(ConfigTypesProtos.BleAdvertisingPeriodMode.BLE_ADVERTISING_PERIOD_MODE_NORMAL) + .setNormal(1600) + .setFast(320) + .build()) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var ble = json.get("bleAdvertisingPeriod").getAsJsonObject(); + + assertThat(ble.get("mode").getAsString()).isEqualTo("BLE_ADVERTISING_PERIOD_MODE_NORMAL"); + assertThat(ble.get("normal").getAsLong()).isEqualTo(1600); + assertThat(ble.get("fast").getAsLong()).isEqualTo(320); + } + + @Test + void getEfentoConfiguration_parsesRepeatedChannelTypes() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_HUMIDITY) + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var channelTypes = json.get("channelTypes").getAsJsonArray(); + + assertThat(channelTypes).hasSize(3); + assertThat(channelTypes.get(0).getAsString()).isEqualTo("MEASUREMENT_TYPE_TEMPERATURE"); + assertThat(channelTypes.get(1).getAsString()).isEqualTo("MEASUREMENT_TYPE_HUMIDITY"); + assertThat(channelTypes.get(2).getAsString()).isEqualTo("MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE"); + } + + @Test + void getEfentoConfiguration_parsesEdgeLogicRules() throws InvalidProtocolBufferException { + // ProtoRule uses channel_mask (bit mask), condition, parameters, action + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .addRules(ProtoRuleProtos.ProtoRule.newBuilder() + .setChannelMask(1) // channel 1 → bit mask 0b000001 + .setCondition(ProtoRuleProtos.Condition.CONDITION_HIGH_THRESHOLD) + .addParameters(500) // threshold value + .setAction(ProtoRuleProtos.Action.ACTION_TRIGGER_TRANSMISSION) + .build()) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var rules = json.get("rules").getAsJsonArray(); + + assertThat(rules).hasSize(1); + var rule = rules.get(0).getAsJsonObject(); + assertThat(rule.get("channelMask").getAsInt()).isEqualTo(1); + assertThat(rule.get("condition").getAsString()).isEqualTo("CONDITION_HIGH_THRESHOLD"); + assertThat(rule.get("parameters").getAsJsonArray().get(0).getAsInt()).isEqualTo(500); + assertThat(rule.get("action").getAsString()).isEqualTo("ACTION_TRIGGER_TRANSMISSION"); + } + + @Test + void getEfentoConfiguration_emptyConfigProducesJsonWithDefaultValues() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder().build().toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + // JsonFormat with includingDefaultValueFields prints all fields — verify a representative subset + assertThat(json.get("measurementPeriodBase").getAsLong()).isEqualTo(0); + assertThat(json.get("transmissionInterval").getAsLong()).isEqualTo(0); + assertThat(json.get("dataServerIp").getAsString()).isEmpty(); + assertThat(json.get("deviceInfoRequest").getAsBoolean()).isFalse(); + assertThat(json.get("cloudToken").getAsString()).isEmpty(); + } + + /** + * Builds a ProtoDeviceInfo with the minimum set of repeated fields required by getEfentoDeviceInfo: + * 22 memory_statistics, 34 modem parameters and 3 message_counters — all set to 0. + */ + private static DeviceInfoProtos.ProtoDeviceInfo.Builder minimalDeviceInfo() { + List zeroMemStats = java.util.Collections.nCopies(22, 0); + List zeroModemParams = java.util.Collections.nCopies(34, 0); + return DeviceInfoProtos.ProtoDeviceInfo.newBuilder() + .setSerialNumber(integerToByteString(1234)) + .setCloudToken("test_token") + .setSwVersion(0) + .addAllMemoryStatistics(zeroMemStats) + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_UNSPECIFIED) + .addAllParameters(zeroModemParams) + .build()) + .setRuntimeInfo(DeviceInfoProtos.ProtoRuntime.newBuilder() + .setUpTime(0) + .addAllMessageCounters(List.of(0, 0, 0)) + .build()); + } + + private static String formatDate(long seconds) { + SimpleDateFormat sdf = new SimpleDateFormat("dd MMM yyyy HH:mm:ss Z"); + return sdf.format(new Date(TimeUnit.SECONDS.toMillis(seconds))); } public static ByteString integerToByteString(Integer intValue) { @@ -340,20 +907,17 @@ class CoapEfentoTransportResourceTest { private void checkDefaultMeasurements(ProtoMeasurements incomingMeasurements, List actualEfentoMeasurements, - long expectedMeasurementInterval, - boolean isBinarySensor) { - for (int i = 0; i < actualEfentoMeasurements.size(); i++) { - CoapEfentoTransportResource.EfentoTelemetry actualEfentoMeasurement = actualEfentoMeasurements.get(i); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNum().toByteArray())); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); - MeasurementsProtos.ProtoChannel protoChannel = incomingMeasurements.getChannelsList().get(0); - long measuredAt = isBinarySensor ? - TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()) + Math.abs(TimeUnit.SECONDS.toMillis(protoChannel.getSampleOffsetsList().get(i))) - 1000 : - TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp() + i * expectedMeasurementInterval); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measured_at").getAsString()).isEqualTo(convertTimestampToUtcString(measuredAt)); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measurement_interval").getAsDouble()).isEqualTo(expectedMeasurementInterval); + long expectedMeasurementInterval) { + CoapEfentoTransportResource.EfentoTelemetry efentoTelemetry = actualEfentoMeasurements.stream() + .sorted(Comparator.comparing(CoapEfentoTransportResource.EfentoTelemetry::getTs)) + .toList().get(0); + + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNumber().toByteArray())); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); + for (int i = 1; i < incomingMeasurements.getChannelsCount() + 1; i++) { + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("measurement_interval_" + i).getAsDouble()).isEqualTo(expectedMeasurementInterval); } } diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index e16a9d7665..9e51de98b4 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.common.transport 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 9a7cfe43ff..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") @@ -213,7 +209,7 @@ public class DeviceApiController implements TbTransportService { return responseWriter; } - @Operation(summary = "Save claiming information (claimDevice)", + @Operation(summary = "Save claiming information (saveClaimingInfo)", description = "Saves the information required for user to claim the device. " + "See more info about claiming in the corresponding 'Claiming devices' platform documentation." + "\n Example of the request payload: " @@ -224,7 +220,7 @@ public class DeviceApiController implements TbTransportService { "In case the secretKey is not specified, the empty string as a default value is used. In case the durationMs is not specified, the system parameter device.claim.duration is used.\n\n" + REQUIRE_ACCESS_TOKEN) @RequestMapping(value = "/{deviceToken}/claim", method = RequestMethod.POST) - public DeferredResult claimDevice( + public DeferredResult saveClaimingInfo( @Parameter(description = ACCESS_TOKEN_PARAM_DESCRIPTION, required = true , schema = @Schema(defaultValue = "YOUR_DEVICE_ACCESS_TOKEN")) @PathVariable("deviceToken") String deviceToken, @RequestBody(required = false) String json) { 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/pom.xml b/common/transport/lwm2m/pom.xml index ed40c3c194..9bf13bb33d 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.common.transport 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/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/pom.xml b/common/transport/mqtt/pom.xml index 504329e92e..87ca63fcba 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.common.transport 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/pom.xml b/common/transport/pom.xml index d6c4e70a75..12f0c87e2c 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 85241fddc1..2f4e2dae76 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport 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/pom.xml b/common/transport/transport-api/pom.xml index 82a53dd836..d02278a5cc 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.common.transport 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..c0d862ec10 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 @@ -789,7 +786,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 +1166,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 +1181,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 +1216,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 +1246,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onError(Throwable e) { callback.onError(e); } + } @Override @@ -1282,4 +1285,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/util/pom.xml b/common/util/pom.xml index d4675a1558..1460119f02 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index 2cf758885f..cf547cb9d4 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java @@ -243,6 +243,14 @@ public class JacksonUtil { } } + public static T treeToValue(JsonNode node, TypeReference type) { + try { + return OBJECT_MAPPER.treeToValue(node, type); + } catch (IOException e) { + throw new IllegalArgumentException("Can't convert value: " + node.toString(), e); + } + } + public static JsonNode toJsonNode(String value) { return toJsonNode(value, OBJECT_MAPPER); } diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 65cfbdf279..1ac78d2db2 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 90a9c888de..7b76c664d8 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard dao 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/attributes/AttributesDao.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java index 2241b20e14..7c563debd6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java @@ -55,6 +55,12 @@ public interface AttributesDao { List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope); + ListenableFuture> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope); + + List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope); + + ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope); + List> removeAllByEntityId(TenantId tenantId, EntityId entityId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java index 117c47b61c..ca4c937d4d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java @@ -45,9 +45,6 @@ import java.util.Optional; import static org.thingsboard.server.dao.attributes.AttributeUtils.validate; -/** - * @author Andrew Shvayka - */ @Service @ConditionalOnProperty(prefix = "cache.attributes", value = "enabled", havingValue = "false", matchIfMissing = true) @Primary @@ -92,7 +89,7 @@ public class BaseAttributesService implements AttributesService { } @Override - public List findAllKeysByEntityIds(TenantId tenantId, List entityIds, AttributeScope scope) { + public List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { if (scope == null) { return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); } else { @@ -100,6 +97,21 @@ public class BaseAttributesService implements AttributesService { } } + @Override + public ListenableFuture> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findAllKeysByEntityIdsAndScopeAsync(tenantId, entityIds, scope); + } + + @Override + public List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findLatestByEntityIdsAndScope(tenantId, entityIds, scope); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findLatestByEntityIdsAndScopeAsync(tenantId, entityIds, scope); + } + @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { validate(entityId, scope); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java index c150eabfa2..1c99dfc495 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java @@ -66,6 +66,7 @@ import static org.thingsboard.server.dao.attributes.AttributeUtils.validate; @Primary @Slf4j public class CachedAttributesService implements AttributesService { + private static final String STATS_NAME = "attributes.cache"; public static final String LOCAL_CACHE_TYPE = "caffeine"; @@ -212,7 +213,7 @@ public class CachedAttributesService implements AttributesService { } @Override - public List findAllKeysByEntityIds(TenantId tenantId, List entityIds, AttributeScope scope) { + public List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { if (scope == null) { return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); } else { @@ -220,6 +221,21 @@ public class CachedAttributesService implements AttributesService { } } + @Override + public ListenableFuture> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findAllKeysByEntityIdsAndScopeAsync(tenantId, entityIds, scope); + } + + @Override + public List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findLatestByEntityIdsAndScope(tenantId, entityIds, scope); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findLatestByEntityIdsAndScopeAsync(tenantId, entityIds, scope); + } + @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { validate(entityId, scope); diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java index 27a52e1cc7..8aef814657 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.validation.constraints.NotNull; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -117,9 +118,9 @@ public class AuditLogServiceImpl implements AuditLogService { @Override public ListenableFuture - logEntityAction(TenantId tenantId, CustomerId customerId, UserId userId, String userName, I entityId, E entity, + logEntityAction(TenantId tenantId, CustomerId customerId, UserId userId, String userName, @NotNull I entityId, E entity, ActionType actionType, Exception e, Object... additionalInfo) { - if (canLog(entityId.getEntityType(), actionType)) { + if (canLog(entityId.getEntityType(), actionType) || (tenantId != null && tenantId.isSysTenantId())) { JsonNode actionData = constructActionData(entityId, entity, actionType, additionalInfo); ActionStatus actionStatus = ActionStatus.SUCCESS; String failureDetails = ""; diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index 00c5ec372e..075e2d8a9d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -26,18 +26,21 @@ 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.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; 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.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.validator.CalculatedFieldDataValidator; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; import java.util.EnumSet; import java.util.List; @@ -62,6 +65,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements private final EntityService entityService; private final CalculatedFieldDao calculatedFieldDao; private final CalculatedFieldDataValidator calculatedFieldDataValidator; + private final ApiLimitService apiLimitService; @Override public CalculatedField save(CalculatedField calculatedField) { @@ -70,6 +74,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements @Override public CalculatedField save(CalculatedField calculatedField, boolean doValidate) { + setConfigurationDefaults(calculatedField); CalculatedField oldCalculatedField = null; if (doValidate) { oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId); @@ -79,6 +84,15 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements return doSave(calculatedField, oldCalculatedField); } + private void setConfigurationDefaults(CalculatedField calculatedField) { + if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config + && config.getScheduledUpdateInterval() == null) { + int minScheduledUpdateInterval = (int) apiLimitService.getLimit( + calculatedField.getTenantId(), DefaultTenantProfileConfiguration::getMinAllowedScheduledUpdateIntervalInSecForCF + ); + config.setScheduledUpdateInterval(minScheduledUpdateInterval); + } + } private CalculatedField doSave(CalculatedField calculatedField, CalculatedField oldCalculatedField) { try { diff --git a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java index fa955fa72e..70f5e94d92 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java @@ -66,7 +66,6 @@ import org.thingsboard.server.dao.entity.EntityCountService; import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; -import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DataValidator; @@ -75,6 +74,7 @@ import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.sql.JpaExecutorService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.exception.DataValidationException; import java.util.ArrayList; import java.util.Collections; @@ -239,9 +239,10 @@ public class EdgeServiceImpl extends AbstractCachedEntityService entityDataByQuery = findEntityIdsByFilterAndSorterColumns(tenantId, customerId, query); - if (entityDataByQuery == null || entityDataByQuery.getData().isEmpty()) { - result = entityDataByQuery; - } else { - // 2 step - find entity data by entity ids from the 1st step - List entities = fetchEntityDataByIdsFromInitialQuery(tenantId, customerId, query, entityDataByQuery.getData()); - result = new PageData<>(entities, entityDataByQuery.getTotalPages(), entityDataByQuery.getTotalElements(), entityDataByQuery.hasNext()); - } - } + result = findEntityDataByQueryInternal(tenantId, customerId, query); } edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs); return result; } + @Override + public ListenableFuture> findEntityDataByQueryAsync(TenantId tenantId, CustomerId customerId, EntityDataQuery query) { + log.trace("Executing findEntityDataByQueryAsync, tenantId [{}], customerId [{}], query [{}]", tenantId, customerId, query); + + try { + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validateId(customerId, id -> INCORRECT_CUSTOMER_ID + id); + validateEntityDataQuery(query); + } catch (Exception e) { + return Futures.immediateFailedFuture(e); + } + + if (edqsService.isApiEnabled() && validForEdqs(query) && !tenantId.isSysTenantId()) { + EdqsRequest request = EdqsRequest.builder() + .entityDataQuery(query) + .build(); + long startNs = System.nanoTime(); + return Futures.transform(processEdqsRequestAsync(tenantId, customerId, request), response -> { + edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs); + return response.getEntityDataQueryResult(); + }, MoreExecutors.directExecutor()); + } + + return jpaExecutorService.submit(() -> { + long startNs = System.nanoTime(); + PageData result = findEntityDataByQueryInternal(tenantId, customerId, query); + edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs); + return result; + }); + } + + private PageData findEntityDataByQueryInternal(TenantId tenantId, CustomerId customerId, EntityDataQuery query) { + if (!isValidForOptimization(query)) { + return entityQueryDao.findEntityDataByQuery(tenantId, customerId, query); + } + // 1 step - find entity data by filter and sort columns + PageData entityDataByQuery = findEntityIdsByFilterAndSorterColumns(tenantId, customerId, query); + if (entityDataByQuery == null || entityDataByQuery.getData().isEmpty()) { + return entityDataByQuery; + } + // 2 step - find entity data by entity ids from the 1st step + List entities = fetchEntityDataByIdsFromInitialQuery(tenantId, customerId, query, entityDataByQuery.getData()); + return new PageData<>(entities, entityDataByQuery.getTotalPages(), entityDataByQuery.getTotalElements(), entityDataByQuery.hasNext()); + } + private boolean validForEdqs(EntityCountQuery query) { // for compatibility with PE return true; } private EdqsResponse processEdqsRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) { - EdqsResponse response; try { - log.debug("[{}] Sending request to EDQS: {}", tenantId, request); - response = edqsApiService.processRequest(tenantId, customerId, request).get(); + return processEdqsRequestAsync(tenantId, customerId, request).get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } - log.debug("[{}] Received response from EDQS: {}", tenantId, response); - if (response.getError() != null) { - throw new RuntimeException(response.getError()); - } - return response; + } + + private ListenableFuture processEdqsRequestAsync(TenantId tenantId, CustomerId customerId, EdqsRequest request) { + log.debug("[{}] Sending request to EDQS: {}", tenantId, request); + return Futures.transform(edqsApiService.processRequest(tenantId, customerId, request), response -> { + log.debug("[{}] Received response from EDQS: {}", tenantId, response); + if (response.getError() != null) { + throw new RuntimeException(response.getError()); + } + return response; + }, MoreExecutors.directExecutor()); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 9f9e4a67e9..69a04f301b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -730,6 +730,7 @@ public class ModelConstants { public static final String CALCULATED_FIELD_CONFIGURATION_VERSION = "configuration_version"; public static final String CALCULATED_FIELD_CONFIGURATION = "configuration"; public static final String CALCULATED_FIELD_VERSION = "version"; + public static final String CALCULATED_FIELD_ADDITIONAL_INFO = ADDITIONAL_INFO_PROPERTY; /** * Tasks constants. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java index 5a507b7fd3..47c1520ac0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java @@ -36,6 +36,7 @@ import org.thingsboard.server.dao.util.mapping.JsonConverter; import java.util.UUID; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_ADDITIONAL_INFO; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_CONFIGURATION; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_CONFIGURATION_VERSION; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_ENTITY_ID; @@ -81,9 +82,11 @@ public class CalculatedFieldEntity extends BaseVersionedEntity @Column(name = DEBUG_SETTINGS) private String debugSettings; - public CalculatedFieldEntity() { - super(); - } + @Convert(converter = JsonConverter.class) + @Column(name = CALCULATED_FIELD_ADDITIONAL_INFO) + private JsonNode additionalInfo; + + public CalculatedFieldEntity() {} public CalculatedFieldEntity(CalculatedField calculatedField) { this.setUuid(calculatedField.getUuidId()); @@ -97,6 +100,7 @@ public class CalculatedFieldEntity extends BaseVersionedEntity this.configuration = JacksonUtil.valueToTree(calculatedField.getConfiguration()); this.version = calculatedField.getVersion(); this.debugSettings = JacksonUtil.toString(calculatedField.getDebugSettings()); + this.additionalInfo = calculatedField.getAdditionalInfo(); } @Override @@ -111,6 +115,7 @@ public class CalculatedFieldEntity extends BaseVersionedEntity calculatedField.setConfiguration(JacksonUtil.treeToValue(configuration, CalculatedFieldConfiguration.class)); calculatedField.setVersion(version); calculatedField.setDebugSettings(JacksonUtil.fromString(debugSettings, DebugSettings.class)); + calculatedField.setAdditionalInfo(additionalInfo); return calculatedField; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java index 44a38bf445..971c06c7a0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java @@ -65,6 +65,12 @@ import static org.thingsboard.server.dao.model.ModelConstants.VERSION_COLUMN; query = SearchTsKvLatestRepository.FIND_ALL_BY_ENTITY_ID_QUERY, resultSetMapping = "tsKvLatestFindMapping", resultClass = TsKvLatestEntity.class + ), + @NamedNativeQuery( + name = SearchTsKvLatestRepository.FIND_LATEST_BY_ENTITY_IDS, + query = SearchTsKvLatestRepository.FIND_LATEST_BY_ENTITY_IDS_QUERY, + resultSetMapping = "tsKvLatestFindMapping", + resultClass = TsKvLatestEntity.class ) }) public final class TsKvLatestEntity extends AbstractTsKvEntity { diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java index b46540aa55..fb8d5eb1d8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -511,12 +511,10 @@ public class DefaultNotifications { rule.setTriggerConfig(defaultRule.getTriggerConfig()); if (rule.getTriggerType() == NotificationRuleTriggerType.ALARM) { EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(rule.getTriggerType()); recipientsConfig.setEscalationTable(Map.of(0, toUUIDs(List.of(targets)))); rule.setRecipientsConfig(recipientsConfig); } else { - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(rule.getTriggerType()); + DefaultNotificationRuleRecipientsConfig recipientsConfig = DefaultNotificationRuleRecipientsConfig.forTriggerType(rule.getTriggerType()); recipientsConfig.setTargets(toUUIDs(List.of(targets))); rule.setRecipientsConfig(recipientsConfig); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java index d71d6502cf..f5ff79fb52 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java @@ -17,15 +17,22 @@ package org.thingsboard.server.dao.pat; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.dao.Dao; +import java.util.List; import java.util.Set; public interface ApiKeyDao extends Dao { ApiKey findByValue(String value); + PageData findByTenantId(TenantId tenantId, PageLink pageLink); + + List findByTenantIdAndUserId(TenantId tenantId, UserId userId); + Set deleteByTenantId(TenantId tenantId); Set deleteByUserId(TenantId tenantId, UserId userId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java index 17a3e2bda3..36d8736f78 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java @@ -24,6 +24,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; @@ -34,9 +35,11 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.service.validator.ApiKeyDataValidator; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -73,13 +76,20 @@ public class ApiKeyServiceImpl extends AbstractCachedEntityService findApiKeysByUserId(TenantId tenantId, UserId userId) { + log.trace("Executing findApiKeysByUserId [{}][{}]", tenantId, userId); + validateId(userId, id -> INCORRECT_USER_ID + id); + return apiKeyDao.findByTenantIdAndUserId(tenantId, userId); + } + @Override public Optional> findEntity(TenantId tenantId, EntityId entityId) { return Optional.ofNullable(findApiKeyById(tenantId, new ApiKeyId(entityId.getId()))); @@ -126,6 +143,20 @@ public class ApiKeyServiceImpl extends AbstractCachedEntityService INCORRECT_API_KEY_ID + id); apiKeyDao.removeById(tenantId, apiKeyId); publishEvictEvent(new ApiKeyEvictEvent(apiKey.getValue())); + eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(apiKey.getId()).build()); + } + + @Override + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + ApiKey apiKey = findApiKeyById(tenantId, new ApiKeyId(id.getId())); + if (apiKey == null) { + if (force) { + return; + } else { + throw new DataValidationException("Unable to delete non-existent API key."); + } + } + deleteApiKey(tenantId, apiKey, force); } @Override @@ -144,6 +175,13 @@ public class ApiKeyServiceImpl extends AbstractCachedEntityService publishEvictEvent(new ApiKeyEvictEvent(value))); } + @Override + public PageData findApiKeysByTenantId(TenantId tenantId, PageLink pageLink) { + log.trace("Executing findApiKeysByTenantId [{}]", tenantId); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + return apiKeyDao.findByTenantId(tenantId, pageLink); + } + @Override public ApiKey findApiKeyByValue(String value) { log.trace("Executing findApiKeyByValue [{}]", value); 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/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/attributes/AttributeKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java index aa7f0490d7..8a3405549e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java @@ -20,6 +20,14 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.dao.model.sql.AttributeKvCompositeKey; import org.thingsboard.server.dao.model.sql.AttributeKvEntity; @@ -60,6 +68,19 @@ public interface AttributeKvRepository extends JpaRepository findAllKeysByEntityIdsAndAttributeType(@Param("entityIds") List entityIds, @Param("attributeType") int attributeType); + @Query(value = """ + SELECT DISTINCT ON (a.attribute_key) + kd.key AS strKey, + a.bool_v AS boolV, a.str_v AS strV, a.long_v AS longV, + a.dbl_v AS dblV, a.json_v AS jsonV, + a.last_update_ts AS lastUpdateTs, a.version AS version + FROM attribute_kv a + INNER JOIN key_dictionary kd ON a.attribute_key = kd.key_id + WHERE a.entity_id IN :entityIds AND a.attribute_type = :attributeType + ORDER BY a.attribute_key, a.last_update_ts DESC""", nativeQuery = true) + List findLatestByEntityIdsAndAttributeType(@Param("entityIds") List entityIds, + @Param("attributeType") int attributeType); + @Query(value = "SELECT attribute_key, attribute_type, entity_id, bool_v, dbl_v, json_v, last_update_ts, long_v, str_v, version FROM attribute_kv WHERE (entity_id, attribute_type, attribute_key) > " + "(:entityId, :attributeType, :attributeKey) ORDER BY entity_id, attribute_type, attribute_key LIMIT :batchSize", nativeQuery = true) List findNextBatch(@Param("entityId") UUID entityId, @@ -67,4 +88,40 @@ public interface AttributeKvRepository extends JpaRepository> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return service.submit(() -> findAllKeysByEntityIdsAndScope(tenantId, entityIds, scope)); + } + + @Override + public List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { + if (CollectionUtils.isEmpty(entityIds)) { + return Collections.emptyList(); + } + var uniqueIds = entityIds.stream().map(EntityId::getId).distinct().toList(); + return attributeKvRepository.findLatestByEntityIdsAndAttributeType(uniqueIds, scope.getId()) + .stream() + .map(AttributeKvRepository.AttributeKvProjection::toAttributeKvEntry) + .toList(); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return service.submit(() -> findLatestByEntityIdsAndScope(tenantId, entityIds, scope)); + } + @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, AttributeKvEntry attribute) { AttributeKvEntity entity = new AttributeKvEntity(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java index f6113155ca..0f9dcc20be 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java @@ -74,6 +74,7 @@ public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedF JsonNode configuration = JacksonUtil.toJsonNode((String) row.get("configuration")); long version = row.get("version") != null ? (long) row.get("version") : 0; String debugSettings = (String) row.get("debug_settings"); + JsonNode additionalInfo = JacksonUtil.toJsonNode((String) row.get("additional_info")); CalculatedField calculatedField = new CalculatedField(); calculatedField.setId(new CalculatedFieldId(id)); @@ -91,6 +92,7 @@ public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedF } calculatedField.setVersion(version); calculatedField.setDebugSettings(JacksonUtil.fromString(debugSettings, DebugSettings.class)); + calculatedField.setAdditionalInfo(additionalInfo); return calculatedField; }).collect(Collectors.toList()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java index e3e560725c..a83223d726 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.sql.pat; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -22,6 +24,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.model.sql.ApiKeyEntity; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -29,6 +32,10 @@ public interface ApiKeyRepository extends JpaRepository { ApiKeyEntity findByValue(String value); + Page findByTenantId(UUID tenantId, Pageable pageable); + + List findByTenantIdAndUserId(UUID tenantId, UUID userId); + @Transactional @Modifying @Query(value = """ diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java index 61ca7bd142..69de36384e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java @@ -22,6 +22,8 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.sql.ApiKeyEntity; @@ -29,6 +31,7 @@ import org.thingsboard.server.dao.pat.ApiKeyDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -45,6 +48,16 @@ public class JpaApiKeyDao extends JpaAbstractDao implement return DaoUtil.getData(apiKeyRepository.findByValue(value)); } + @Override + public PageData findByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(apiKeyRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + + @Override + public List findByTenantIdAndUserId(TenantId tenantId, UserId userId) { + return DaoUtil.convertDataList(apiKeyRepository.findByTenantIdAndUserId(tenantId.getId(), userId.getId())); + } + @Override public Set deleteByTenantId(TenantId tenantId) { return apiKeyRepository.deleteByTenantId(tenantId.getId()); 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 a9b04618c8..59964d1949 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; @@ -60,6 +61,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; @@ -318,10 +320,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; @@ -332,6 +343,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()); @@ -502,11 +522,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); } } } @@ -525,6 +546,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 b9a134e713..51c926263f 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 @@ -511,8 +511,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); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java index eabbdfed80..143ab53e18 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java @@ -172,4 +172,14 @@ public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseries return sqlDao.findAllKeysByEntityIdsAsync(tenantId, entityIds); } + @Override + public List findLatestByEntityIds(TenantId tenantId, List entityIds) { + return sqlDao.findLatestByEntityIds(tenantId, entityIds); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds) { + return sqlDao.findLatestByEntityIdsAsync(tenantId, entityIds); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java index d807794ddc..b33ceca63b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java @@ -22,6 +22,7 @@ import com.google.common.util.concurrent.MoreExecutors; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -54,6 +55,7 @@ import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; import org.thingsboard.server.dao.util.SqlTsLatestAnyDao; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -189,6 +191,21 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme return service.submit(() -> findAllKeysByEntityIds(tenantId, entityIds)); } + @Override + public List findLatestByEntityIds(TenantId tenantId, List entityIds) { + if (CollectionUtils.isEmpty(entityIds)) { + return Collections.emptyList(); + } + return DaoUtil.convertDataList( + searchTsKvLatestRepository.findLatestByEntityIds(entityIds.stream().map(EntityId::getId).toList()) + ); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds) { + return service.submit(() -> findLatestByEntityIds(tenantId, entityIds)); + } + private ListenableFuture getNewLatestEntryFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query, Long version) { ListenableFuture> future = findNewLatestEntryFuture(tenantId, entityId, query); return Futures.transformAsync(future, entryList -> { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java index 0ab1994059..8b3a66612c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java @@ -34,6 +34,20 @@ public class SearchTsKvLatestRepository { " ts_kv_latest.bool_v AS boolValue, ts_kv_latest.long_v AS longValue, ts_kv_latest.dbl_v AS doubleValue, ts_kv_latest.json_v AS jsonValue, ts_kv_latest.ts AS ts, ts_kv_latest.version AS version FROM ts_kv_latest " + "INNER JOIN key_dictionary ON ts_kv_latest.key = key_dictionary.key_id WHERE ts_kv_latest.entity_id = cast(:id AS uuid)"; + public static final String FIND_LATEST_BY_ENTITY_IDS = "findLatestByEntityIds"; + + public static final String FIND_LATEST_BY_ENTITY_IDS_QUERY = """ + SELECT DISTINCT ON (ts_kv_latest.key) + ts_kv_latest.entity_id AS entityId, ts_kv_latest.key AS key, + key_dictionary.key AS strKey, ts_kv_latest.str_v AS strValue, + ts_kv_latest.bool_v AS boolValue, ts_kv_latest.long_v AS longValue, + ts_kv_latest.dbl_v AS doubleValue, ts_kv_latest.json_v AS jsonValue, + ts_kv_latest.ts AS ts, ts_kv_latest.version AS version + FROM ts_kv_latest + INNER JOIN key_dictionary ON ts_kv_latest.key = key_dictionary.key_id + WHERE ts_kv_latest.entity_id IN (:entityIds) + ORDER BY ts_kv_latest.key, ts_kv_latest.ts DESC"""; + @PersistenceContext private EntityManager entityManager; @@ -43,4 +57,10 @@ public class SearchTsKvLatestRepository { .getResultList(); } + public List findLatestByEntityIds(List entityIds) { + return entityManager.createNamedQuery(FIND_LATEST_BY_ENTITY_IDS, TsKvLatestEntity.class) + .setParameter("entityIds", entityIds) + .getResultList(); + } + } 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 INCORRECT_TENANT_ID + id); validateId(tenantProfileId, id -> INCORRECT_TENANT_PROFILE_ID + id); @@ -198,22 +198,18 @@ public class TenantProfileServiceImpl extends AbstractCachedEntityService 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/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 747197470c..70e04eb5e3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -55,9 +55,6 @@ import java.util.stream.Collectors; import static org.thingsboard.server.common.data.StringUtils.isBlank; -/** - * @author Andrew Shvayka - */ @Service @Slf4j public class BaseTimeseriesService implements TimeseriesService { @@ -161,6 +158,16 @@ public class BaseTimeseriesService implements TimeseriesService { return timeseriesLatestDao.findAllKeysByEntityIdsAsync(tenantId, entityIds); } + @Override + public List findLatestByEntityIds(TenantId tenantId, List entityIds) { + return timeseriesLatestDao.findLatestByEntityIds(tenantId, entityIds); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds) { + return timeseriesLatestDao.findLatestByEntityIdsAsync(tenantId, entityIds); + } + @Override public void cleanup(long systemTtl) { timeseriesDao.cleanup(systemTtl); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java index b9ce090177..e9bf3db134 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java @@ -104,6 +104,16 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes return Futures.immediateFuture(Collections.emptyList()); } + @Override + public List findLatestByEntityIds(TenantId tenantId, List entityIds) { + return Collections.emptyList(); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds) { + return Futures.immediateFuture(Collections.emptyList()); + } + @Override public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { BoundStatementBuilder stmtBuilder = new BoundStatementBuilder(getLatestStmt().bind()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java index 26e784b760..c7582944a9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java @@ -52,4 +52,13 @@ public interface TimeseriesLatestDao { ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds); + /** + * For each unique timeseries key across the given entities, returns the single most recent {@link TsKvEntry} + * (i.e. the entry with the highest timestamp). If the same key exists on multiple entities, + * only the freshest value is kept. Useful for discovering available keys together with a representative sample value. + */ + List findLatestByEntityIds(TenantId tenantId, List entityIds); + + ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds); + } diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 3d9c9f17e6..d5e359b241 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -935,6 +935,7 @@ CREATE TABLE IF NOT EXISTS calculated_field ( configuration varchar(1000000), version BIGINT DEFAULT 1, debug_settings varchar(1024), + additional_info varchar, CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, type, name) ); 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/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index 9a467071ef..5ee9af6292 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -93,11 +93,13 @@ public abstract class AbstractServiceTest { @Autowired protected EntityServiceRegistry entityServiceRegistry; + protected Tenant tenant; protected TenantId tenantId; @Before public void beforeAbstractService() { - tenantId = createTenant().getId(); + tenant = createTenant(); + tenantId = tenant.getId(); } @After 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/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index ba914c6d35..3caf4bb5f4 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -15,10 +15,13 @@ */ package org.thingsboard.server.dao.service; +import org.apache.commons.lang3.RandomUtils; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; @@ -29,6 +32,10 @@ import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -39,6 +46,7 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.exception.DataValidationException; import java.util.ArrayList; @@ -59,6 +67,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { private DeviceService deviceService; @Autowired private TbTenantProfileCache tbTenantProfileCache; + @Autowired + private TenantProfileService tenantProfileService; @Test public void testSaveCalculatedField() { @@ -74,6 +84,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); assertThat(savedCalculatedField.getConfiguration()).isEqualTo(calculatedField.getConfiguration()); + assertThat(savedCalculatedField.getAdditionalInfo()).isEqualTo(calculatedField.getAdditionalInfo()); assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); savedCalculatedField.setName("Test CF"); @@ -81,9 +92,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { CalculatedField updatedCalculatedField = calculatedFieldService.save(savedCalculatedField); assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getAdditionalInfo()).isEqualTo(savedCalculatedField.getAdditionalInfo()); assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); - - calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); } @Test @@ -113,11 +123,11 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { int min = tbTenantProfileCache.get(tenantId) .getDefaultProfileConfiguration() .getMinAllowedScheduledUpdateIntervalInSecForCF(); - int valueFromConfig = min - 10; // Enable scheduling with an interval below tenant min cfg.setScheduledUpdateEnabled(true); - cfg.setScheduledUpdateInterval(valueFromConfig); + int invalidInterval = RandomUtils.insecure().randomInt(1, min); + cfg.setScheduledUpdateInterval(invalidInterval); // Create & save Calculated Field CalculatedField cf = new CalculatedField(); @@ -131,8 +141,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThatThrownBy(() -> calculatedFieldService.save(cf)) .isInstanceOf(DataValidationException.class) .hasCauseInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: "); + .hasMessage("Scheduled update interval (" + invalidInterval + + " seconds) is less than minimum allowed interval in tenant profile: " + min + " seconds"); } @Test @@ -233,8 +243,67 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval(); assertThat(savedInterval).isEqualTo(valueFromConfig); + } - calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); + @Test + public void testSaveGeofencingCalculatedField_shouldAcceptZeroScheduledUpdateIntervalWhenTenantProfileAllows() { + // GIVEN + var device = createTestDevice(); + + // Store original value and update tenant profile to allow 0 as min scheduled update interval + TenantProfile tenantProfile = tenantProfileService.findTenantProfileById(tenantId, tenant.getTenantProfileId()); + int originalMinScheduledUpdateInterval = tenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF(); + tenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(0); + tenantProfileService.saveTenantProfile(tenantId, tenantProfile); + tbTenantProfileCache.evict(tenantProfile.getId()); + + try { + // Build a valid Geofencing configuration + var cfg = new GeofencingCalculatedFieldConfiguration(); + + // Coordinates: TS_LATEST, no dynamic source + var entityCoordinates = new EntityCoordinates("latitude", "longitude"); + cfg.setEntityCoordinates(entityCoordinates); + + // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE))); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); + cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); + + // Enable scheduling with interval = 0 + cfg.setScheduledUpdateEnabled(true); + cfg.setScheduledUpdateInterval(0); + + // Create Calculated Field + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.GEOFENCING); + cf.setName("GF zero scheduled update interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + var out = new AttributesOutput(); + out.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(out); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(GeofencingCalculatedFieldConfiguration.class); + + var savedConfig = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration(); + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(0); + } finally { + // Restore original tenant profile value + tenantProfile.getProfileConfiguration().orElseThrow().setMinAllowedScheduledUpdateIntervalInSecForCF(originalMinScheduledUpdateInterval); + tenantProfileService.saveTenantProfile(tenantId, tenantProfile); + tbTenantProfileCache.evict(tenantProfile.getId()); + } } @Test @@ -254,8 +323,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { CalculatedField fetchedCalculatedField = calculatedFieldService.findById(tenantId, savedCalculatedField.getId()); assertThat(fetchedCalculatedField).isEqualTo(savedCalculatedField); - - calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); } @Test @@ -267,6 +334,156 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThat(calculatedFieldService.findById(tenantId, savedCalculatedField.getId())).isNull(); } + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldUseMinScheduledUpdateIntervalFromTenantProfileWhenNotSet() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + // Do NOT set scheduledUpdateInterval - it should default to tenant profile min value + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - default scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(RelatedEntitiesAggregationCalculatedFieldConfiguration.class); + + var savedConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) saved.getConfiguration(); + int expectedMinScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(expectedMinScheduledUpdateInterval); + } + + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldThrowWhenScheduledUpdateIntervalLessThanMinAllowed() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + int minScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int invalidInterval = RandomUtils.insecure().randomInt(1, minScheduledUpdateInterval); + cfg.setScheduledUpdateInterval(invalidInterval); + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - invalid scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN-THEN + assertThatThrownBy(() -> calculatedFieldService.save(cf)) + .isInstanceOf(DataValidationException.class) + .hasCauseInstanceOf(IllegalArgumentException.class) + .hasMessage("Scheduled update interval (" + invalidInterval + + " seconds) is less than minimum allowed interval in tenant profile: " + minScheduledUpdateInterval + " seconds"); + } + + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldAcceptValidScheduledUpdateInterval() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + int minScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int customScheduledUpdateInterval = minScheduledUpdateInterval + 100; + cfg.setScheduledUpdateInterval(customScheduledUpdateInterval); + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - valid scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(RelatedEntitiesAggregationCalculatedFieldConfiguration.class); + + var savedConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) saved.getConfiguration(); + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(customScheduledUpdateInterval); + } + private CalculatedField saveValidCalculatedField() { Device device = createTestDevice(); CalculatedField calculatedField = getCalculatedField(device.getId(), device.getId()); @@ -281,6 +498,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { calculatedField.setName("Test Calculated Field"); calculatedField.setConfigurationVersion(1); calculatedField.setConfiguration(getCalculatedFieldConfig(referencedEntityId)); + calculatedField.setAdditionalInfo(JacksonUtil.newObjectNode()); return calculatedField; } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/TenantProfileServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/TenantProfileServiceTest.java index d3436fa9d1..1d8e77f8ce 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/TenantProfileServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/TenantProfileServiceTest.java @@ -164,13 +164,13 @@ public class TenantProfileServiceTest extends AbstractServiceTest { TenantProfile savedTenantProfile1 = tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, tenantProfile1); TenantProfile savedTenantProfile2 = tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, tenantProfile2); - boolean result = tenantProfileService.setDefaultTenantProfile(TenantId.SYS_TENANT_ID, savedTenantProfile1.getId()); - Assert.assertTrue(result); + TenantProfile setDefaultTenantProfile1 = tenantProfileService.setDefaultTenantProfile(TenantId.SYS_TENANT_ID, savedTenantProfile1.getId()); + Assert.assertNotEquals(savedTenantProfile1.isDefault(), setDefaultTenantProfile1.isDefault()); TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(TenantId.SYS_TENANT_ID); Assert.assertNotNull(defaultTenantProfile); Assert.assertEquals(savedTenantProfile1.getId(), defaultTenantProfile.getId()); - result = tenantProfileService.setDefaultTenantProfile(TenantId.SYS_TENANT_ID, savedTenantProfile2.getId()); - Assert.assertTrue(result); + TenantProfile setDefaultTenantProfile2 = tenantProfileService.setDefaultTenantProfile(TenantId.SYS_TENANT_ID, savedTenantProfile2.getId()); + Assert.assertNotEquals(savedTenantProfile2.isDefault(), setDefaultTenantProfile2.isDefault()); defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(TenantId.SYS_TENANT_ID); Assert.assertNotNull(defaultTenantProfile); Assert.assertEquals(savedTenantProfile2.getId(), defaultTenantProfile.getId()); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java index d3aa0fde0f..373112293d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java @@ -23,7 +23,6 @@ import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; import org.awaitility.Awaitility; import org.junit.Assert; -import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.AttributeScope; @@ -31,8 +30,12 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +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; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.dao.attributes.AttributesDao; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.service.AbstractServiceTest; @@ -40,6 +43,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.Executors; @@ -48,9 +52,6 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; -/** - * @author Andrew Shvayka - */ @Slf4j public abstract class BaseAttributesServiceTest extends AbstractServiceTest { @@ -60,9 +61,8 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { @Autowired private AttributesService attributesService; - @Before - public void before() { - } + @Autowired + private AttributesDao attributesDao; @Test public void saveAndFetch() throws Exception { @@ -223,7 +223,7 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key2", "123"); Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { - List keys = attributesService.findAllKeysByEntityIds(tenantId, List.of(deviceId), AttributeScope.SERVER_SCOPE); + List keys = attributesService.findAllKeysByEntityIdsAndScope(tenantId, List.of(deviceId), AttributeScope.SERVER_SCOPE); assertThat(keys).containsOnly("key1", "key2"); }); } @@ -241,6 +241,84 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { }); } + @Test + public void findLatestByEntityIdsAndScope_returnsOneEntryPerKey() { + var device1 = new DeviceId(UUID.randomUUID()); + var device2 = new DeviceId(UUID.randomUUID()); + + // Both devices have "temperature", device2 has a newer ts + saveAttribute(tenantId, device1, AttributeScope.SERVER_SCOPE, 1000, new DoubleDataEntry("temperature", 20.0)); + saveAttribute(tenantId, device2, AttributeScope.SERVER_SCOPE, 2000, new DoubleDataEntry("temperature", 25.0)); + // Only device1 has "humidity" + saveAttribute(tenantId, device1, AttributeScope.SERVER_SCOPE, 1500, new LongDataEntry("humidity", 60L)); + // Only device2 has "active" + saveAttribute(tenantId, device2, AttributeScope.SERVER_SCOPE, 3000, new BooleanDataEntry("active", true)); + + List results = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device1, device2), AttributeScope.SERVER_SCOPE); + Map byKey = results.stream().collect(Collectors.toMap(AttributeKvEntry::getKey, e -> e)); + + Assert.assertEquals(3, byKey.size()); + + // "temperature" should pick device2's value (ts=2000 > ts=1000) + AttributeKvEntry temp = byKey.get("temperature"); + Assert.assertNotNull(temp); + Assert.assertEquals(25.0, temp.getDoubleValue().orElseThrow(), 0.0); + Assert.assertEquals(2000, temp.getLastUpdateTs()); + + // "humidity" — only device1 has it + AttributeKvEntry humidity = byKey.get("humidity"); + Assert.assertNotNull(humidity); + Assert.assertEquals(60L, (long) humidity.getLongValue().orElseThrow()); + Assert.assertEquals(1500, humidity.getLastUpdateTs()); + + // "active" — only device2 has it + AttributeKvEntry active = byKey.get("active"); + Assert.assertNotNull(active); + Assert.assertEquals(true, active.getBooleanValue().orElseThrow()); + Assert.assertEquals(3000, active.getLastUpdateTs()); + } + + @Test + public void findLatestByEntityIdsAndScope_emptyList() { + List results = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(), AttributeScope.SERVER_SCOPE); + Assert.assertTrue(results.isEmpty()); + } + + @Test + public void findLatestByEntityIdsAndScope_singleEntity() throws Exception { + var device = new DeviceId(UUID.randomUUID()); + saveAttribute(tenantId, device, AttributeScope.SERVER_SCOPE, 1000, new StringDataEntry("key1", "value1")); + saveAttribute(tenantId, device, AttributeScope.SERVER_SCOPE, 2000, new StringDataEntry("key2", "value2")); + + // sync + List results = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device), AttributeScope.SERVER_SCOPE); + Assert.assertEquals(2, results.size()); + Map byKey = results.stream().collect(Collectors.toMap(AttributeKvEntry::getKey, e -> e)); + Assert.assertEquals("value1", byKey.get("key1").getStrValue().orElseThrow()); + Assert.assertEquals(1000, byKey.get("key1").getLastUpdateTs()); + Assert.assertEquals("value2", byKey.get("key2").getStrValue().orElseThrow()); + Assert.assertEquals(2000, byKey.get("key2").getLastUpdateTs()); + + // async — same result + List asyncResults = attributesDao.findLatestByEntityIdsAndScopeAsync(tenantId, List.of(device), AttributeScope.SERVER_SCOPE).get(); + Assert.assertEquals(results, asyncResults); + } + + @Test + public void findLatestByEntityIdsAndScope_filtersScope() { + var device = new DeviceId(UUID.randomUUID()); + saveAttribute(tenantId, device, AttributeScope.SERVER_SCOPE, 1000, new StringDataEntry("serverKey", "sv")); + saveAttribute(tenantId, device, AttributeScope.CLIENT_SCOPE, 1000, new StringDataEntry("clientKey", "cv")); + + List serverResults = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device), AttributeScope.SERVER_SCOPE); + Assert.assertEquals(1, serverResults.size()); + Assert.assertEquals("serverKey", serverResults.get(0).getKey()); + + List clientResults = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device), AttributeScope.CLIENT_SCOPE); + Assert.assertEquals(1, clientResults.size()); + Assert.assertEquals("clientKey", clientResults.get(0).getKey()); + } + private void testConcurrentFetchAndUpdate(TenantId tenantId, DeviceId deviceId, ListeningExecutorService pool) throws Exception { var scope = AttributeScope.SERVER_SCOPE; var key = "TEST"; @@ -313,6 +391,15 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { } } + private void saveAttribute(TenantId tenantId, DeviceId deviceId, AttributeScope scope, long ts, KvEntry value) { + try { + attributesService.save(tenantId, deviceId, scope, Collections.singletonList(new BaseAttributeKvEntry(ts, value))).get(10, TimeUnit.SECONDS); + } catch (Exception e) { + log.warn("Failed to save attribute", e.getCause()); + throw new RuntimeException(e); + } + } + private void equalsIgnoreVersion(AttributeKvEntry expected, AttributeKvEntry actual) { Assert.assertEquals(expected.getKey(), actual.getKey()); Assert.assertEquals(expected.getValue(), actual.getValue()); 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/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java index 170b452fee..373344cb6d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java @@ -16,29 +16,35 @@ package org.thingsboard.server.dao.service.validator; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.dao.tenant.TenantProfileDao; import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.exception.DataValidationException; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; @SpringBootTest(classes = TenantProfileDataValidator.class) class TenantProfileDataValidatorTest { - @MockBean + @MockitoBean TenantProfileDao tenantProfileDao; - @MockBean + @MockitoBean TenantProfileService tenantProfileService; - @SpyBean + @MockitoSpyBean TenantProfileDataValidator validator; + TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da")); @Test @@ -53,4 +59,44 @@ class TenantProfileDataValidatorTest { verify(validator).validateString("Tenant profile name", tenantProfile.getName()); } + @ParameterizedTest + @ValueSource(ints = {-1, -100, Integer.MIN_VALUE}) + void minAllowedScheduledUpdateIntervalInSecForCF_shouldRejectNegativeValues(int value) { + // GIVEN + var config = new DefaultTenantProfileConfiguration(); + config.setMinAllowedScheduledUpdateIntervalInSecForCF(value); + + var tenantProfileData = new TenantProfileData(); + tenantProfileData.setConfiguration(config); + + var tenantProfile = new TenantProfile(); + tenantProfile.setName("Test"); + tenantProfile.setProfileData(tenantProfileData); + + // WHEN/THEN + assertThatThrownBy(() -> validator.validate(tenantProfile, __ -> TenantId.SYS_TENANT_ID)) + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("minAllowedScheduledUpdateIntervalInSecForCF") + .hasMessageContaining("must be greater than or equal to 0"); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 60, Integer.MAX_VALUE}) + void minAllowedScheduledUpdateIntervalInSecForCF_shouldAcceptValidValues(int value) { + // GIVEN + var config = new DefaultTenantProfileConfiguration(); + config.setMinAllowedScheduledUpdateIntervalInSecForCF(value); + + var tenantProfileData = new TenantProfileData(); + tenantProfileData.setConfiguration(config); + + var tenantProfile = new TenantProfile(); + tenantProfile.setName("Test"); + tenantProfile.setProfileData(tenantProfileData); + + // WHEN/THEN + assertThatCode(() -> validator.validate(tenantProfile, __ -> TenantId.SYS_TENANT_ID)) + .doesNotThrowAnyException(); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java index 96f3390b13..02abf1e5fd 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java @@ -22,6 +22,9 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.id.DeviceId; 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.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.service.AbstractServiceTest; @@ -30,7 +33,9 @@ import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -102,6 +107,69 @@ public class SqlTimeseriesLatestDaoTest extends AbstractServiceTest { } } + @Test + public void findLatestByEntityIds_returnsOneEntryPerKey() throws Exception { + DeviceId device1 = new DeviceId(UUID.randomUUID()); + DeviceId device2 = new DeviceId(UUID.randomUUID()); + + // Both devices have "temperature" key, device2 has a newer ts + timeseriesLatestDao.saveLatest(tenantId, device1, new BasicTsKvEntry(1000, new DoubleDataEntry("temperature", 20.0))).get(); + timeseriesLatestDao.saveLatest(tenantId, device2, new BasicTsKvEntry(2000, new DoubleDataEntry("temperature", 25.0))).get(); + // Only device1 has "humidity" + timeseriesLatestDao.saveLatest(tenantId, device1, new BasicTsKvEntry(1500, new LongDataEntry("humidity", 60L))).get(); + // Only device2 has "active" + timeseriesLatestDao.saveLatest(tenantId, device2, new BasicTsKvEntry(3000, new BooleanDataEntry("active", true))).get(); + + List results = timeseriesLatestDao.findLatestByEntityIds(tenantId, List.of(device1, device2)); + Map byKey = results.stream().collect(Collectors.toMap(TsKvEntry::getKey, e -> e)); + + assertEquals(3, byKey.size()); + + // "temperature" should pick device2's value (ts=2000 > ts=1000) + TsKvEntry temp = byKey.get("temperature"); + assertNotNull(temp); + assertEquals(25.0, temp.getDoubleValue().orElseThrow()); + assertEquals(2000, temp.getTs()); + + // "humidity" — only device1 has it + TsKvEntry humidity = byKey.get("humidity"); + assertNotNull(humidity); + assertEquals(60L, humidity.getLongValue().orElseThrow()); + assertEquals(1500, humidity.getTs()); + + // "active" — only device2 has it + TsKvEntry active = byKey.get("active"); + assertNotNull(active); + assertEquals(true, active.getBooleanValue().orElseThrow()); + assertEquals(3000, active.getTs()); + } + + @Test + public void findLatestByEntityIds_emptyList() { + List results = timeseriesLatestDao.findLatestByEntityIds(tenantId, List.of()); + assertTrue(results.isEmpty()); + } + + @Test + public void findLatestByEntityIds_singleEntity() throws Exception { + DeviceId device = new DeviceId(UUID.randomUUID()); + timeseriesLatestDao.saveLatest(tenantId, device, new BasicTsKvEntry(1000, new StringDataEntry("key1", "value1"))).get(); + timeseriesLatestDao.saveLatest(tenantId, device, new BasicTsKvEntry(2000, new StringDataEntry("key2", "value2"))).get(); + + // sync + List results = timeseriesLatestDao.findLatestByEntityIds(tenantId, List.of(device)); + assertEquals(2, results.size()); + Map byKey = results.stream().collect(Collectors.toMap(TsKvEntry::getKey, e -> e)); + assertEquals("value1", byKey.get("key1").getStrValue().orElseThrow()); + assertEquals(1000, byKey.get("key1").getTs()); + assertEquals("value2", byKey.get("key2").getStrValue().orElseThrow()); + assertEquals(2000, byKey.get("key2").getTs()); + + // async — same result + List asyncResults = timeseriesLatestDao.findLatestByEntityIdsAsync(tenantId, List.of(device)).get(); + assertEquals(results, asyncResults); + } + private TsKvEntry createEntry(String key, long ts) { return new BasicTsKvEntry(ts, new StringDataEntry(key, RandomStringUtils.random(10))); } 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/sql/system-data.sql b/dao/src/test/resources/sql/system-data.sql index 0b17d4f108..2cd038aae7 100644 --- a/dao/src/test/resources/sql/system-data.sql +++ b/dao/src/test/resources/sql/system-data.sql @@ -66,3 +66,5 @@ VALUES ( '6eaaefa6-4612-11e7-a919-92ebcb67fe33', 1592576748000 ,'13814000-1dd2-1 '{"type": "BURST", "batchSize": 1000}', '{"type": "SKIP_ALL_FAILURES", "retries": 3, "failurePercentage": 0.0, "pauseBetweenRetries": 3, "maxPauseBetweenRetries": 3}' ); + +INSERT INTO tb_schema_settings (schema_version, product) VALUES (999999000000, 'CE'); 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/pom.xml b/edqs/pom.xml index 763a60e991..32c6df82f2 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard edqs diff --git a/edqs/src/main/resources/edqs.yml b/edqs/src/main/resources/edqs.yml index 010994dc88..4dc404e2ca 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 b2a4e8c1e3..5bc1fb5baa 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 08df511db1..b871933d9f 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java index ebf90bb7f0..ec1bdcf658 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java @@ -44,7 +44,6 @@ import java.util.Map; import java.util.Random; import java.util.function.Consumer; - @Slf4j @Listeners(TestListener.class) public abstract class AbstractContainerTest { @@ -170,19 +169,14 @@ public abstract class AbstractContainerTest { DeviceProfileProvisionConfiguration provisionConfiguration; String testProvisionDeviceKey = TEST_PROVISION_DEVICE_KEY; deviceProfile.setProvisionType(provisionType); - switch(provisionType) { - case ALLOW_CREATE_NEW_DEVICES: - provisionConfiguration = new AllowCreateNewDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); - break; - case CHECK_PRE_PROVISIONED_DEVICES: - provisionConfiguration = new CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); - break; - default: - case DISABLED: + provisionConfiguration = switch (provisionType) { + case ALLOW_CREATE_NEW_DEVICES -> new AllowCreateNewDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); + case CHECK_PRE_PROVISIONED_DEVICES -> new CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); + default -> { testProvisionDeviceKey = null; - provisionConfiguration = new DisabledDeviceProfileProvisionConfiguration(null); - break; - } + yield new DisabledDeviceProfileProvisionConfiguration(null); + } + }; DeviceProfileData deviceProfileData = deviceProfile.getProfileData(); deviceProfileData.setProvisionConfiguration(provisionConfiguration); deviceProfile.setProfileData(deviceProfileData); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java index 91c4927b3c..df29e26e5e 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java @@ -358,6 +358,16 @@ public class TestRestClient { .statusCode(HTTP_OK); } + public JsonNode testRuleChainScript(Object body) { + return given().spec(requestSpec) + .body(body) + .post("/api/ruleChain/testScript") + .then() + .statusCode(HTTP_OK) + .extract() + .as(JsonNode.class); + } + private String getUrlParams(PageLink pageLink) { String urlParams = "pageSize={pageSize}&page={page}"; if (!isEmpty(pageLink.getTextSearch())) { diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java index c9ebfad3e2..24e7e78039 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.msa.connectivity; import com.google.gson.JsonObject; +import org.apache.commons.lang3.RandomStringUtils; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; @@ -26,7 +27,6 @@ import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.core5.ssl.SSLContexts; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; -import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; @@ -34,6 +34,10 @@ import org.testng.annotations.Test; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rest.client.RestClient; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileInfo; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; @@ -41,6 +45,11 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.asset.AssetProfileInfo; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.domain.DomainInfo; import org.thingsboard.server.common.data.id.NotificationTargetId; @@ -94,6 +103,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -109,8 +119,10 @@ public class JavaRestClientTest extends AbstractContainerTest { public static final String DEFAULT_NOTIFICATION_SUBJECT = "Just a test"; public static final NotificationType DEFAULT_NOTIFICATION_TYPE = NotificationType.GENERAL; private RestClient restClient; - private Tenant tenant; - private User user; + private Tenant tenant1; + private Tenant tenant2; + private User tenantAdmin1; + private User tenantAdmin2; @BeforeClass public void beforeClass() throws Exception { @@ -140,31 +152,45 @@ public class JavaRestClientTest extends AbstractContainerTest { public void setUp() throws Exception { restClient.login("sysadmin@thingsboard.org", "sysadmin"); - // create tenant and tenant admin - tenant = new Tenant(); - tenant.setTitle("Java Rest Client Test Tenant " + RandomStringUtils.randomAlphabetic(5)); - tenant = restClient.saveTenant(tenant); + // create tenant 1 and tenant admin 1 + tenant1 = new Tenant(); + tenant1.setTitle("Java Rest Client Test Tenant " + RandomStringUtils.insecure().randomAlphabetic(5)); + tenant1 = restClient.saveTenant(tenant1); - String email = RandomStringUtils.randomAlphabetic(5) + "@gmail.com"; - user = restClient.saveUser(defaultTenantAdmin(tenant.getId(), email), false); - restClient.activateUser(user.getId(), "password123", false); - restClient.login(email, "password123"); + String email1 = RandomStringUtils.insecure().randomAlphabetic(5) + "@gmail.com"; + tenantAdmin1 = restClient.saveUser(defaultTenantAdmin(tenant1.getId(), email1), false); + restClient.activateUser(tenantAdmin1.getId(), "password123", false); + + // create tenant 2 and tenant admin 2 + tenant2 = new Tenant(); + tenant2.setTitle("Java Rest Client Test Tenant " + RandomStringUtils.insecure().randomAlphabetic(5)); + tenant2 = restClient.saveTenant(tenant2); + + String email2 = RandomStringUtils.insecure().randomAlphabetic(5) + "@gmail.com"; + tenantAdmin2 = restClient.saveUser(defaultTenantAdmin(tenant2.getId(), email2), false); + restClient.activateUser(tenantAdmin2.getId(), "password123", false); + + // tenant 1 tenant admin by default + restClient.login(tenantAdmin1.getEmail(), "password123"); } @AfterMethod public void tearDown() { restClient.login("sysadmin@thingsboard.org", "sysadmin"); - if (tenant != null) { - restClient.deleteTenant(tenant.getId()); + if (tenant1 != null) { + restClient.deleteTenant(tenant1.getId()); + } + if (tenant2 != null) { + restClient.deleteTenant(tenant2.getId()); } } @Test public void testGetAlarmsV2() { - Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.randomAlphabetic(5))); + Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.insecure().randomAlphabetic(5))); assertThat(device).isNotNull(); - String type = "High temp" + RandomStringUtils.randomAlphabetic(5); + String type = "High temp" + RandomStringUtils.insecure().randomAlphabetic(5); Alarm alarm = Alarm.builder() .originator(device.getId()) .severity(AlarmSeverity.CRITICAL) @@ -202,7 +228,7 @@ public class JavaRestClientTest extends AbstractContainerTest { @Test public void testTimeSeriesByReadTsKvQueries() { - Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.randomAlphabetic(5))); + Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.insecure().randomAlphabetic(5))); assertThat(device).isNotNull(); DeviceCredentials deviceCredentials = restClient.getDeviceCredentialsByDeviceId(device.getId()).get(); @@ -230,7 +256,7 @@ public class JavaRestClientTest extends AbstractContainerTest { @Test public void testFindNotifications() { - NotificationTarget notificationTarget = createNotificationTarget(user.getId()); + NotificationTarget notificationTarget = createNotificationTarget(tenantAdmin1.getId()); String notificationText1 = "Notification 1"; NotificationTemplate notificationTemplate = createNotificationTemplate(DEFAULT_NOTIFICATION_TYPE, DEFAULT_NOTIFICATION_SUBJECT, notificationText1, new NotificationDeliveryMethod[]{WEB}); NotificationRequest notificationRequest = submitNotificationRequest(notificationTarget.getId(), notificationTemplate.getId()); @@ -248,7 +274,7 @@ public class JavaRestClientTest extends AbstractContainerTest { NotificationRequestPreview requestPreview = restClient.getNotificationRequestPreview(notificationRequest, 10); assertThat(requestPreview.getTotalRecipientsCount()).isEqualTo(1); - assertThat(requestPreview.getRecipientsPreview()).isEqualTo(List.of(user.getEmail())); + assertThat(requestPreview.getRecipientsPreview()).isEqualTo(List.of(tenantAdmin1.getEmail())); PageData notifications = restClient.getNotifications(false, WEB, new PageLink(30)); assertThat(notifications.getTotalElements()).isEqualTo(2); @@ -316,7 +342,7 @@ public class JavaRestClientTest extends AbstractContainerTest { restClient.login("sysadmin@thingsboard.org", "sysadmin"); Domain domain = new Domain(); - String prefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); + String prefix = RandomStringUtils.insecure().randomAlphabetic(5).toLowerCase(); domain.setName(prefix + ".test.com"); Domain savedDomain = restClient.saveDomain(domain); assertThat(savedDomain.getName()).isEqualTo(domain.getName()); @@ -330,10 +356,10 @@ public class JavaRestClientTest extends AbstractContainerTest { restClient.login("sysadmin@thingsboard.org", "sysadmin"); MobileApp mobileApp = new MobileApp(); - String prefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); + String prefix = RandomStringUtils.insecure().randomAlphabetic(5).toLowerCase(); mobileApp.setPkgName(prefix + "test.app.apple"); mobileApp.setPlatformType(PlatformType.ANDROID); - mobileApp.setAppSecret(RandomStringUtils.randomAlphabetic(20)); + mobileApp.setAppSecret(RandomStringUtils.insecure().randomAlphabetic(20)); mobileApp.setStatus(MobileAppStatus.DRAFT); MobileApp savedMobileApp = restClient.saveMobileApp(mobileApp); @@ -343,7 +369,7 @@ public class JavaRestClientTest extends AbstractContainerTest { assertThat(retrieved.getData()).hasSize(1); MobileAppBundle mobileAppBundle = new MobileAppBundle(); - String bundlePrefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); + String bundlePrefix = RandomStringUtils.insecure().randomAlphabetic(5).toLowerCase(); mobileAppBundle.setTitle(bundlePrefix + "Test Bundle"); mobileAppBundle.setAndroidAppId(savedMobileApp.getId()); @@ -357,7 +383,7 @@ public class JavaRestClientTest extends AbstractContainerTest { filter.setUsersIds(Arrays.stream(usersIds).map(UUIDBased::getId).toList()); NotificationTarget notificationTarget = new NotificationTarget(); - notificationTarget.setName(filter + RandomStringUtils.randomNumeric(5)); + notificationTarget.setName(filter + RandomStringUtils.insecure().randomNumeric(5)); PlatformUsersNotificationTargetConfig targetConfig = new PlatformUsersNotificationTargetConfig(); targetConfig.setUsersFilter(filter); notificationTarget.setConfiguration(targetConfig); @@ -367,7 +393,7 @@ public class JavaRestClientTest extends AbstractContainerTest { private NotificationTemplate createNotificationTemplate(NotificationType notificationType, String subject, String text, NotificationDeliveryMethod... deliveryMethods) { NotificationTemplate notificationTemplate = new NotificationTemplate(); - notificationTemplate.setName("Notification template: " + RandomStringUtils.randomAlphabetic(5)); + notificationTemplate.setName("Notification template: " + RandomStringUtils.insecure().randomAlphabetic(5)); notificationTemplate.setNotificationType(notificationType); NotificationTemplateConfig config = new NotificationTemplateConfig(); config.setDeliveryMethodsTemplates(new HashMap<>()); @@ -418,9 +444,9 @@ public class JavaRestClientTest extends AbstractContainerTest { public void testApiKeyOperations() { // Create an API key ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); - apiKeyInfo.setDescription("Test API Key " + RandomStringUtils.randomAlphabetic(5)); + apiKeyInfo.setDescription("Test API Key " + RandomStringUtils.insecure().randomAlphabetic(5)); apiKeyInfo.setEnabled(true); - apiKeyInfo.setUserId(user.getId()); + apiKeyInfo.setUserId(tenantAdmin1.getId()); apiKeyInfo.setExpirationTime(0); ApiKey savedApiKey = restClient.saveApiKey(apiKeyInfo); @@ -428,18 +454,18 @@ public class JavaRestClientTest extends AbstractContainerTest { assertThat(savedApiKey.getId()).isNotNull(); assertThat(savedApiKey.getDescription()).isEqualTo(apiKeyInfo.getDescription()); assertThat(savedApiKey.isEnabled()).isTrue(); - assertThat(savedApiKey.getUserId()).isEqualTo(user.getId()); - assertThat(savedApiKey.getTenantId()).isEqualTo(tenant.getId()); + assertThat(savedApiKey.getUserId()).isEqualTo(tenantAdmin1.getId()); + assertThat(savedApiKey.getTenantId()).isEqualTo(tenant1.getId()); assertThat(savedApiKey.getValue()).isNotNull(); // Get user API keys - PageData apiKeys = restClient.getUserApiKeys(user.getId(), new PageLink(10)); + PageData apiKeys = restClient.getUserApiKeys(tenantAdmin1.getId(), new PageLink(10)); assertThat(apiKeys).isNotNull(); assertThat(apiKeys.getData()).hasSize(1); assertThat(apiKeys.getData().get(0).getId()).isEqualTo(savedApiKey.getId()); // Update API key description - String updatedDescription = "Updated description " + RandomStringUtils.randomAlphabetic(5); + String updatedDescription = "Updated description " + RandomStringUtils.insecure().randomAlphabetic(5); ApiKeyInfo updatedApiKeyInfo = restClient.updateApiKeyDescription(savedApiKey.getId(), updatedDescription); assertThat(updatedApiKeyInfo).isNotNull(); assertThat(updatedApiKeyInfo.getDescription()).isEqualTo(updatedDescription); @@ -458,8 +484,96 @@ public class JavaRestClientTest extends AbstractContainerTest { restClient.deleteApiKey(savedApiKey.getId()); // Verify the API key is deleted - PageData apiKeysAfterDelete = restClient.getUserApiKeys(user.getId(), new PageLink(10)); + PageData apiKeysAfterDelete = restClient.getUserApiKeys(tenantAdmin1.getId(), new PageLink(10)); assertThat(apiKeysAfterDelete.getData()).isEmpty(); } + @Test + public void testGetDeviceProfileInfosByIds() { + var profileData = new DeviceProfileData(); + profileData.setConfiguration(new DefaultDeviceProfileConfiguration()); + profileData.setTransportConfiguration(new DefaultDeviceProfileTransportConfiguration()); + + // Create a device profile in tenant1 (current tenant) + var deviceProfile1 = new DeviceProfile(); + deviceProfile1.setTenantId(tenant1.getId()); + deviceProfile1.setName("Device Profile 1"); + deviceProfile1.setType(DeviceProfileType.DEFAULT); + deviceProfile1.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile1.setProfileData(profileData); + deviceProfile1 = restClient.saveDeviceProfile(deviceProfile1); + + var deviceProfile2 = new DeviceProfile(); + deviceProfile2.setTenantId(tenant1.getId()); + deviceProfile2.setName("Device Profile 2"); + deviceProfile2.setType(DeviceProfileType.DEFAULT); + deviceProfile2.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile2.setProfileData(profileData); + deviceProfile2 = restClient.saveDeviceProfile(deviceProfile2); + + // Create two more device profiles in tenant2 (different tenant) + restClient.login(tenantAdmin2.getEmail(), "password123"); + + var deviceProfile3 = new DeviceProfile(); + deviceProfile3.setTenantId(tenant2.getId()); + deviceProfile3.setName("Device Profile 3"); + deviceProfile3.setType(DeviceProfileType.DEFAULT); + deviceProfile3.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile3.setProfileData(profileData); + deviceProfile3 = restClient.saveDeviceProfile(deviceProfile3); + + var deviceProfile4 = new DeviceProfile(); + deviceProfile4.setTenantId(tenant2.getId()); + deviceProfile4.setName("Device Profile 4"); + deviceProfile4.setType(DeviceProfileType.DEFAULT); + deviceProfile4.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile4.setProfileData(profileData); + deviceProfile4 = restClient.saveDeviceProfile(deviceProfile4); + + // Attempt to fetch profiles 1 and 3 while acting as Tenant 2. + // - Profile 1: Filtered out because it belongs to a different tenant. + // - Profile 2: Filtered out because it belongs to a different tenant and was not requested. + // - Profile 3: Should be returned. + // - Profile 4: Filtered out; it belongs to the correct tenant, but was not requested by ID. + List profiles = restClient.getDeviceProfileInfosByIds(Set.of(deviceProfile1.getUuidId(), deviceProfile3.getUuidId())); + assertThat(profiles).hasSize(1); + assertThat(profiles.get(0).getId()).isEqualTo(deviceProfile3.getId()); + } + + @Test + public void testGetAssetProfilesByIds() { + // Create two asset profiles in tenant1 (current tenant) + var assetProfile1 = new AssetProfile(); + assetProfile1.setTenantId(tenant1.getId()); + assetProfile1.setName("Asset Profile 1"); + assetProfile1 = restClient.saveAssetProfile(assetProfile1); + + var assetProfile2 = new AssetProfile(); + assetProfile2.setTenantId(tenant1.getId()); + assetProfile2.setName("Asset Profile 2"); + assetProfile2 = restClient.saveAssetProfile(assetProfile2); + + // Create two more asset profiles in tenant2 (different tenant) + restClient.login(tenantAdmin2.getEmail(), "password123"); + + var assetProfile3 = new AssetProfile(); + assetProfile3.setTenantId(tenant2.getId()); + assetProfile3.setName("Asset Profile 3"); + assetProfile3 = restClient.saveAssetProfile(assetProfile3); + + var assetProfile4 = new AssetProfile(); + assetProfile4.setTenantId(tenant2.getId()); + assetProfile4.setName("Asset Profile 4"); + assetProfile4 = restClient.saveAssetProfile(assetProfile4); + + // Attempt to fetch profiles 1 and 3 while acting as Tenant 2. + // - Profile 1: Filtered out because it belongs to a different tenant. + // - Profile 2: Filtered out because it belongs to a different tenant and was not requested. + // - Profile 3: Should be returned. + // - Profile 4: Filtered out; it belongs to the correct tenant, but was not requested by ID. + List profiles = restClient.getAssetProfilesByIds(Set.of(assetProfile1.getUuidId(), assetProfile3.getUuidId())); + assertThat(profiles).hasSize(1); + assertThat(profiles.get(0).getId()).isEqualTo(assetProfile3.getId()); + } + } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/security/JsExecutorSandboxIsolationTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/security/JsExecutorSandboxIsolationTest.java new file mode 100644 index 0000000000..1328382c5d --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/security/JsExecutorSandboxIsolationTest.java @@ -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. + */ +package org.thingsboard.server.msa.security; + +import com.fasterxml.jackson.databind.JsonNode; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; +import org.thingsboard.server.msa.AbstractContainerTest; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JsExecutorSandboxIsolationTest extends AbstractContainerTest { + + @BeforeClass + public void beforeClass() { + testRestClient.login("tenant@thingsboard.org", "tenant"); + } + + @AfterClass + public void afterClass() { + testRestClient.resetToken(); + } + + /** + * Black-box regression for JVN#16937365: a tenant admin must not be able + * to escape the tb-js-executor sandbox via the host-realm prototype chain + * exposed through the script's `args` argument. Runs against the live + * docker-compose deployment, which uses script.use_sandbox=true and + * JS_EVALUATOR=remote (Kafka -> tb-js-executor). + */ + @Test + public void testRuleChainScriptCannotReachHostProcess() { + JsonNode response = testRestClient.testRuleChainScript(""" + { + "script": "var F = args.constructor.constructor; var p = F('return process')(); return { reachedHost: !!(p && p.mainModule) };", + "scriptType": "update", + "argNames": ["msg", "metadata", "msgType"], + "msg": "{}", + "metadata": {}, + "msgType": "POST_TELEMETRY_REQUEST" + } + """); + + // The sandboxed run must reject the escape attempt: the host `process` + // global is not defined inside the sandbox realm, so executing the + // synthesized function `F("return process")` throws. + assertThat(response.has("error")).isTrue(); + String error = response.get("error").asText(); + assertThat(error) + .as("sandbox must block host-realm reach via args.constructor.constructor; full error: %s", error) + .contains("process is not defined"); + + // Defense in depth: even if the script somehow returned, output must + // not indicate that the host process was reached. + if (response.hasNonNull("output")) { + assertThat(response.get("output").asText()).doesNotContain("\"reachedHost\":true"); + } + } +} diff --git a/msa/black-box-tests/src/test/resources/connectivity.xml b/msa/black-box-tests/src/test/resources/connectivity.xml index a1cbaf4af6..7cd96bb429 100644 --- a/msa/black-box-tests/src/test/resources/connectivity.xml +++ b/msa/black-box-tests/src/test/resources/connectivity.xml @@ -25,6 +25,7 @@ + \ No newline at end of file diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index 9c4c4f20ec..c9fcd7beb7 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/api/jsExecutor.ts b/msa/js-executor/api/jsExecutor.ts index 855ad99932..ab916a2760 100644 --- a/msa/js-executor/api/jsExecutor.ts +++ b/msa/js-executor/api/jsExecutor.ts @@ -15,14 +15,22 @@ /// import vm, { Script } from 'vm'; +import { _logger } from '../config/logger'; export type TbScript = Script | Function; export class JsExecutor { useSandbox: boolean; + private logger = _logger('JsExecutor'); constructor(useSandbox: boolean) { this.useSandbox = useSandbox; + if (!useSandbox) { + this.logger.warn( + 'script.use_sandbox=false: dangerous by design — user-supplied scripts run in the host realm with no isolation. ' + + 'Use only as a performance trade-off in trusted, non-public clusters.' + ); + } } compileScript(code: string): Promise { @@ -56,9 +64,15 @@ export class JsExecutor { private invokeScript(script: Script, args: string[], timeout: number | undefined): Promise { return new Promise((resolve, reject) => { try { - const sandbox = Object.create(null); - sandbox.args = args; - const result = script.runInNewContext(sandbox, {timeout: timeout}); + const sandbox = vm.createContext(Object.create(null)); + // Construct args inside the sandbox context so it inherits sandbox-realm + // prototypes; prevents prototype-based escapes from the host realm. + const ctxArgs = vm.runInContext('[]', sandbox) as string[]; + for (let i = 0; i < args.length; i++) { + ctxArgs[i] = String(args[i]); + } + sandbox.args = ctxArgs; + const result = script.runInContext(sandbox, {timeout: timeout}); resolve(result); } catch (err) { reject(err); @@ -67,6 +81,11 @@ export class JsExecutor { } + // DANGEROUS BY DESIGN: the non-sandbox path. vm.compileFunction's + // parsingContext only isolates *parsing*, not *execution* — the resulting + // function runs in the host realm with full access to host globals + // (process, require, etc.). Enabled only via script.use_sandbox=false as + // a performance trade-off in trusted clusters. private createFunction(code: string): Promise { return new Promise((resolve, reject) => { try { 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 be49ece5a6..9a33190069 100644 --- a/msa/js-executor/config/default.yml +++ b/msa/js-executor/config/default.yml @@ -34,7 +34,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" # The default value in kafkajs is: 30000 connectionTimeout: "1000" # The default value in kafkajs is: 1000 - compression: "none" # gzip or uncompressed + compression: "none" # gzip, lz4 or none topic_properties: "retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1" use_confluent_cloud: false client_id: "kafkajs" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh @@ -50,6 +50,12 @@ logger: filename: "tb-js-executor-%DATE%.log" script: + # WARNING: setting this to "false" is DANGEROUS BY DESIGN. The non-sandbox + # path compiles and runs user-supplied scripts in the host realm via + # vm.compileFunction; it provides no isolation and exposes the host process + # (file system, environment variables, child_process, etc.) to script + # authors. Use "false" only as a performance trade-off in trusted, + # non-public clusters where every script author is fully trusted. use_sandbox: "true" memory_usage_trace_frequency: "1000" script_body_trace_frequency: "10000" 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 ea5ec75879..5e0c41fe85 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,18 +1,19 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.3.1.1", + "version": "4.3.1.2", "description": "ThingsBoard JavaScript Executor Microservice", "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", - "test": "echo \"Error: no test specified\" && exit 1", + "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": "mkdir -p target/surefire-reports && node --require ts-node/register --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=target/surefire-reports/TEST-js-executor.xml test/jsExecutor.test.ts", "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 dd1ddc492a..4396f4fb96 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa org.thingsboard.msa @@ -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,18 @@ compile - run pkg + --mutex network run pkg + + + + yarn test + + yarn + + test + + ${maven.test.skip} + --mutex network run test 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/test/jsExecutor.test.ts b/msa/js-executor/test/jsExecutor.test.ts new file mode 100644 index 0000000000..7777030ce9 --- /dev/null +++ b/msa/js-executor/test/jsExecutor.test.ts @@ -0,0 +1,83 @@ +/// +/// 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 { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JsExecutor } from '../api/jsExecutor'; + +// describe('js-executor') groups all cases under +// in the JUnit XML so they show up under that suite in TeamCity's Tests tab, +// alongside thousands of Java tests. +describe('js-executor', () => { + +test('sandbox isolates args from host realm (JVN#16937365)', async () => { + const exec = new JsExecutor(true); + const script = await exec.compileScript(`function(msg, metadata, msgType){ + var F = args.constructor.constructor; + var p = F("return process")(); + return p && p.mainModule ? 'reached-host' : 'isolated'; + }`); + await assert.rejects( + exec.executeScript(script, ['{}', '{}', 'POST_TELEMETRY_REQUEST'], 5000), + /process is not defined/, + 'host process must not be reachable from inside the sandbox', + ); +}); + +test('sandbox passes string args through unchanged', async () => { + const exec = new JsExecutor(true); + const script = await exec.compileScript(`function(msg, metadata, msgType){ + return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] }; + }`); + const out = await exec.executeScript(script, ['hello', '{}', 'X'], 5000); + // Field-by-field: the returned object is owned by the sandbox realm, so + // its prototype is not the host Object.prototype and deepStrictEqual would + // reject it on prototype mismatch even when the values match. + assert.equal(out.msgIsString, true); + assert.equal(out.count, 3); + assert.equal(out.first, 'hello'); +}); + +// The use_sandbox=false path is intentionally non-isolating: scripts compile +// and run in the host realm via vm.compileFunction. The two tests below codify +// that documented contract so any future behavior change shows up as a test +// failure and forces a deliberate update of the docs and threat model. + +test('non-sandbox path does not isolate from host realm (documented contract)', async () => { + const exec = new JsExecutor(false); + const script = await exec.compileScript(`function(msg, metadata, msgType){ + // Non-destructive host-reach probe: typeof process.platform is 'string' + // only if the host process object is reachable. + var F = args.constructor.constructor; + return F('return typeof process.platform')(); + }`); + const out = await exec.executeScript(script, ['{}', '{}', 'X']); + assert.equal(out, 'string', + 'use_sandbox=false is documented as non-isolating; if this fails, the path was changed and docs/threat model must be updated'); +}); + +test('non-sandbox path passes string args through unchanged', async () => { + const exec = new JsExecutor(false); + const script = await exec.compileScript(`function(msg, metadata, msgType){ + return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] }; + }`); + const out = await exec.executeScript(script, ['hello', '{}', 'X']); + assert.equal(out.msgIsString, true); + assert.equal(out.count, 3); + assert.equal(out.first, 'hello'); +}); + +}); // describe('js-executor') diff --git a/msa/js-executor/tsconfig.json b/msa/js-executor/tsconfig.json index b633ffc768..a1f7b25466 100644 --- a/msa/js-executor/tsconfig.json +++ b/msa/js-executor/tsconfig.json @@ -9,5 +9,5 @@ "skipLibCheck": true, "strictPropertyInitialization": false }, - "exclude": ["node_modules", "target"] + "exclude": ["node_modules", "target", "test"] } 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/monitoring/pom.xml b/msa/monitoring/pom.xml index 7fd897ec35..2b4a0bd6da 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index 45b4cd4152..a3494dfc3e 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard msa @@ -44,7 +44,16 @@ - + tb web-ui vc-executor diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 2ee4c6979a..e4ff8f4951 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile index 34a90e6fb7..9570358c1e 100644 --- a/msa/tb/docker-cassandra/Dockerfile +++ b/msa/tb/docker-cassandra/Dockerfile @@ -42,6 +42,10 @@ ENV CASSANDRA_LOG=/var/log/cassandra 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 \ && echo "deb http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null \ @@ -49,7 +53,9 @@ RUN apt-get update \ && echo "deb https://debian.cassandra.apache.org 40x main" | tee -a /etc/apt/sources.list.d/cassandra.sources.list > /dev/null \ && wget -q https://downloads.apache.org/cassandra/KEYS -O- | apt-key add - \ && 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/* \ && update-rc.d cassandra disable \ && update-rc.d postgresql disable \ diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 6536d258f0..6b14bb92fb 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index d30de2527d..27a0210adf 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index c1a15eb936..fb22d39da3 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 408a10500b..916dc57712 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index fbdca5d9f6..1dc673ce47 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index a85f0fce97..a1e867fb05 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index 6bb66f8a68..9ec7400a2c 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.3.1.1 + 4.3.1.2-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index 3f73bd8966..5f37f101f4 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 6d9e706d46..f5beccabf6 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa org.thingsboard.msa 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 8314985622..ab806701e5 100644 --- a/msa/vc-executor/src/main/resources/tb-vc-executor.yml +++ b/msa/vc-executor/src/main/resources/tb-vc-executor.yml @@ -73,7 +73,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 @@ -215,6 +215,8 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" 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 32355edffa..ce18b0b063 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,12 +1,12 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.3.1.1", + "version": "4.3.1.2", "description": "ThingsBoard Web UI Microservice", "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 0ed3f58b15..a6b6fb6e8e 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT msa org.thingsboard.msa @@ -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/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index a40a8572f1..9e06b2d975 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard netty-mqtt - 4.3.1.1 + 4.3.1.2-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 635b737031..306879f4fa 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT pom Thingsboard @@ -62,17 +62,23 @@ ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 3.5.12 - 2.21.1 - 4.1.132.Final + 4.3.1.2 + 3.5.14 + + 3.5.13 + 3.18.0 + 42.7.11 + 4.1.133.Final + 10.1.55 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 @@ -89,8 +95,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 @@ -98,11 +104,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/**/* @@ -112,8 +119,7 @@ - 3.9.1 - 1.10.1 + 3.9.2 8.10.1 3.5.3 1.12.701 @@ -138,6 +144,7 @@ 1.7.5 3.8.0 1.8.0-TB + 2.5.9 2.38.0 1.24 1.11.0 @@ -619,7 +626,6 @@ org.apache.maven.plugins maven-assembly-plugin - ${pkg.skip.zip} ${pkg.name} ${main.dir}/packaging/${pkg.type}/assembly/windows.xml @@ -632,6 +638,9 @@ single + + ${pkg.skip.zip} + @@ -916,6 +925,12 @@ **/resources/lwm2m/models/** src/main/data/resources/** .claude/** + **/lombok.config + **/eslint.config.mjs + **/config.monitoring + **/valkey-certs/** + **/data/certs/** + **/*.otf JAVADOC_STYLE @@ -968,6 +983,7 @@ org.apache.maven.plugins maven-clean-plugin + ${maven-clean-plugin.version} false @@ -993,15 +1009,6 @@ - - - com.fasterxml.jackson - jackson-bom - ${jackson-bom.version} - pom - import - - io.netty @@ -1011,6 +1018,23 @@ import + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + + + org.apache.tomcat.embed + tomcat-embed-el + ${tomcat.version} + + + org.apache.tomcat.embed + tomcat-embed-websocket + ${tomcat.version} + + org.springframework.boot spring-boot-dependencies @@ -1177,6 +1201,12 @@ ${project.version} test + + org.thingsboard.client + thingsboard-ce-client + ${thingsboard.client.version} + test + org.thingsboard.msa js-executor @@ -1257,21 +1287,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 @@ -1346,6 +1378,16 @@ commons-lang3 ${commons-lang3.version} + + org.postgresql + postgresql + ${postgresql.version} + + + org.apache.opennlp + opennlp-tools + ${opennlp-tools.version} + commons-io commons-io @@ -1547,12 +1589,6 @@ org.apache.cassandra cassandra-all ${cassandra-all.version} - - - org.lz4 - lz4-java - - org.testng @@ -2018,7 +2054,7 @@ https://repo1.maven.org/maven2/ - thingsboard-repo + thingsboard-public-repo https://repo.thingsboard.io/artifactory/libs-release-public diff --git a/rest-client/pom.xml b/rest-client/pom.xml index e3b5eb4dbc..fe159973cf 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard rest-client diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 102fb90d1f..f133527ddc 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -22,8 +22,8 @@ import com.google.common.base.Strings; import lombok.Getter; import lombok.SneakyThrows; import org.apache.commons.io.IOUtils; -import org.apache.hc.core5.net.URIBuilder; import org.apache.commons.lang3.concurrent.LazyInitializer; +import org.apache.hc.core5.net.URIBuilder; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; @@ -160,17 +160,18 @@ import org.thingsboard.server.common.data.oauth2.PlatformType; import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.pat.ApiKey; -import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.common.data.plugin.ComponentDescriptor; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AvailableEntityKeys; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -218,6 +219,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -226,6 +228,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static java.util.stream.Collectors.joining; import static org.thingsboard.server.common.data.StringUtils.isEmpty; public class RestClient implements Closeable { @@ -1593,6 +1596,33 @@ public class RestClient implements Closeable { }, activeOnly).getBody(); } + public List getDeviceProfileInfosByIds(Set ids) { + URIBuilder builder; + try { + builder = new URIBuilder(baseURL); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid base URL: " + baseURL, e); + } + + builder.appendPath("/api/deviceProfileInfos"); + + String commaSeparatedIds = ids.stream() + .filter(Objects::nonNull) + .map(UUID::toString) + .collect(joining(",")); + + builder.addParameter("deviceProfileIds", commaSeparatedIds); + + URI uri; + try { + uri = builder.build(); + } catch (URISyntaxException e) { + throw new IllegalStateException("Failed to construct API URI from base URL and provided params", e); + } + + return restTemplate.exchange(uri, HttpMethod.GET, null, new ParameterizedTypeReference>() {}).getBody(); + } + public JsonNode claimDevice(String deviceName, ClaimRequest claimRequest) { return restTemplate.exchange( baseURL + "/api/customer/device/{deviceName}/claim", @@ -1829,6 +1859,33 @@ public class RestClient implements Closeable { }, params).getBody(); } + public List getAssetProfilesByIds(Set ids) { + URIBuilder builder; + try { + builder = new URIBuilder(baseURL); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid base URL: " + baseURL, e); + } + + builder.appendPath("/api/assetProfileInfos"); + + String commaSeparatedIds = ids.stream() + .filter(Objects::nonNull) + .map(UUID::toString) + .collect(joining(",")); + + builder.addParameter("assetProfileIds", commaSeparatedIds); + + URI uri; + try { + uri = builder.build(); + } catch (URISyntaxException e) { + throw new IllegalStateException("Failed to construct API URI from base URL and provided params", e); + } + + return restTemplate.exchange(uri, HttpMethod.GET, null, new ParameterizedTypeReference>() {}).getBody(); + } + public Long countEntitiesByQuery(EntityCountQuery query) { return restTemplate.postForObject(baseURL + "/api/entitiesQuery/count", query, Long.class); } @@ -1841,6 +1898,10 @@ public class RestClient implements Closeable { }).getBody(); } + /** + * @deprecated Use {@link #findAvailableEntityKeysV2(EntityDataQuery, boolean, boolean, Set, boolean)} instead. + */ + @Deprecated(forRemoval = true) public AvailableEntityKeys findAvailableEntityKeysByQuery(EntityDataQuery query, boolean includeTimeseries, boolean includeAttributes, AttributeScope scope) { var uri = UriComponentsBuilder.fromUriString(baseURL) .path("/api/entitiesQuery/find/keys") @@ -1852,6 +1913,22 @@ public class RestClient implements Closeable { return restTemplate.exchange(uri, HttpMethod.POST, new HttpEntity<>(query), new ParameterizedTypeReference() {}).getBody(); } + @SneakyThrows(URISyntaxException.class) + public AvailableEntityKeysV2 findAvailableEntityKeysV2( + EntityDataQuery query, boolean includeTimeseries, boolean includeAttributes, Set scopes, boolean includeSamples + ) { + var builder = new URIBuilder(baseURL).appendPath("/api/v2/entitiesQuery/find/keys") + .addParameter("includeTimeseries", String.valueOf(includeTimeseries)) + .addParameter("includeAttributes", String.valueOf(includeAttributes)) + .addParameter("includeSamples", String.valueOf(includeSamples)); + if (scopes != null) { + for (AttributeScope scope : scopes) { + builder.addParameter("scopes", scope.name()); + } + } + return restTemplate.exchange(builder.build(), HttpMethod.POST, new HttpEntity<>(query), new ParameterizedTypeReference() {}).getBody(); + } + public PageData findAlarmDataByQuery(AlarmDataQuery query) { return restTemplate.exchange( baseURL + "/api/alarmsQuery/find", diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index c40f954e6b..3368f77cdc 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 10b5ac1c84..b4643ece39 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java index e4cb573cfe..f12d59cd0a 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java @@ -19,11 +19,14 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.msg.queue.TbCallback; public interface JobManager { ListenableFuture submitJob(Job job); // TODO: rate limits + ListenableFuture submitJob(Job job, TbCallback finishCallback); + void cancelJob(TenantId tenantId, JobId jobId); void reprocessJob(TenantId tenantId, JobId jobId); 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-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 1cc93f07b6..1f25541bd9 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT rule-engine org.thingsboard.rule-engine @@ -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 df9ce0194b..de1e73cb66 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 @@ -33,6 +33,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; @@ -42,6 +43,7 @@ import org.thingsboard.rule.engine.credentials.CredentialsType; import org.thingsboard.server.common.data.StringUtils; 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; @@ -53,8 +55,11 @@ import java.util.Base64; import java.util.List; import java.util.Map; 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; @@ -84,17 +89,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 @@ -155,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 { @@ -207,54 +247,150 @@ 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 + } + } - String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); + 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); + } + + /** + * 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()); HttpMethod method = HttpMethod.valueOf(config.getRequestMethod()); URI uri = buildEncodedUri(endpointUrl); - 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(getData(task.msg(), config.isParseToPlainText()))); } - 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(); + } } } 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 0217edf5e0..02ed33138d 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; @@ -58,7 +59,11 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class); - 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 diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java index a33d25b038..8b31c5a5ac 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java @@ -27,6 +27,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.mockserver.integration.ClientAndServer; import org.springframework.util.LinkedMultiValueMap; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; @@ -156,6 +157,7 @@ public class TbHttpClientTest { .build(); var ctx = mock(TbContext.class); + when(ctx.getExternalCallExecutor()).thenReturn(DirectListeningExecutor.INSTANCE); when(ctx.transformMsg( eq(msg), eq(msg.getMetaData()), 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 a93417275f..cc2daa2421 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 @@ -27,13 +27,18 @@ import org.apache.http.protocol.HttpRequestHandler; import org.junit.jupiter.api.AfterEach; 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; @@ -43,6 +48,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.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -53,15 +59,23 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; 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.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 { + static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30); + @Spy private TbRestApiCallNode restNode; @@ -125,6 +139,8 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } }); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setRequestMethod("DELETE"); config.setHeaders(Collections.singletonMap("Foo", "Bar")); @@ -148,7 +164,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()); @@ -183,6 +199,8 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } }); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setRequestMethod("DELETE"); config.setHeaders(Collections.singletonMap("Foo", "Bar")); @@ -206,12 +224,143 @@ 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()); + } + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of( // config for version 2 with upgrade from version 0 diff --git a/tools/pom.xml b/tools/pom.xml index c27460ec34..7a83b99e1f 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard tools @@ -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/pom.xml b/transport/coap/pom.xml index f53621ae80..78e7ba7f71 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 09138c2639..f55f155b45 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -170,6 +170,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 coap: @@ -276,8 +285,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 @@ -421,6 +430,8 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" diff --git a/transport/http/pom.xml b/transport/http/pom.xml index d0f4e16074..0fad7a5e63 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index f869534088..69e610a812 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -201,6 +201,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 queue: @@ -226,7 +235,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 @@ -370,6 +379,8 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index a06bfa4c02..11d4c1f2db 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 08a4040fbe..05fe2c2956 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -301,6 +301,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 queue: @@ -326,8 +335,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 @@ -471,6 +480,8 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index c84c1e494f..58b04e5bba 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index ccbd3901ce..1ff998560a 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -234,6 +234,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 queue: @@ -259,8 +268,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 @@ -404,6 +413,8 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" diff --git a/transport/pom.xml b/transport/pom.xml index 3899c99fc7..835f98f0ad 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index effef8a10a..3158a9efaa 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT transport diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index 656c01524d..bb46478477 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -207,8 +207,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 @@ -359,6 +359,8 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 36433a5338..082d2ad965 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.3.1.1", + "version": "4.3.1.2", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", @@ -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 abae23a7cd..7c73c7bc33 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.1 + 4.3.1.2-SNAPSHOT thingsboard org.thingsboard @@ -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/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/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index c2d4fc9e21..ab2160ec0f 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -816,7 +816,8 @@ const defaultUserMenuMap = new Map([ {id: MenuId.domains}, {id: MenuId.clients} ] - } + }, + {id: MenuId.audit_log} ] } ] 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 cadac572c6..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); @@ -157,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)); } @@ -214,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 { @@ -258,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: { @@ -351,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]; @@ -373,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 c7fd7da063..a5903e1019 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 @@ -138,6 +138,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/api-key/api-keys-table-config.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts index b2b26ee51e..1522990d6b 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts @@ -59,7 +59,6 @@ export class ApiKeysTableConfig extends EntityTableConfig { this.entityType = EntityType.API_KEY; this.detailsPanelEnabled = false; - this.addAsTextButton = true; this.pageMode = false; this.entityTranslations = entityTypeTranslations.get(EntityType.API_KEY); 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 6d758b8c7b..c7d204884b 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 @@ -34,7 +34,7 @@ - - - - - - + - - - @@ -118,6 +94,72 @@ matTooltipPosition="above"> search +
+ + + + + + + + + + + + + + + + +
+ + + 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/vc/entity-versions-table.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html index cc3fb23da5..829c1414db 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html @@ -32,7 +32,8 @@ -
+
-
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index b703da79f6..095b122af6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -150,6 +150,9 @@ import { ValueStepperBasicConfigComponent } from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component'; import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component'; +import { + HtmlContainerBasicConfigComponent +} from '@home/components/widget/config/basic/html/html-container-basic-config.component'; @NgModule({ declarations: [ @@ -201,7 +204,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma LabelValueCardBasicConfigComponent, UnreadNotificationBasicConfigComponent, ScadaSymbolBasicConfigComponent, - MapBasicConfigComponent + MapBasicConfigComponent, + HtmlContainerBasicConfigComponent ], imports: [ CommonModule, @@ -255,7 +259,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma LabelCardBasicConfigComponent, LabelValueCardBasicConfigComponent, UnreadNotificationBasicConfigComponent, - MapBasicConfigComponent + MapBasicConfigComponent, + HtmlContainerBasicConfigComponent ] }) export class BasicWidgetConfigModule { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html new file mode 100644 index 0000000000..298bdb6e61 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html @@ -0,0 +1,20 @@ + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts new file mode 100644 index 0000000000..d057acf057 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts @@ -0,0 +1,62 @@ +/// +/// 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 { Component, HostBinding } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { + htmlContainerDefaultSettings, + HtmlContainerWidgetSettings +} from '@home/components/widget/lib/html/html-container-widget.models'; + +@Component({ + selector: 'tb-html-container-basic-config', + templateUrl: './html-container-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false +}) +export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent { + + @HostBinding('style.height') height = '100%'; + + htmlContainerWidgetConfigForm: UntypedFormGroup; + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.htmlContainerWidgetConfigForm; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})}; + this.htmlContainerWidgetConfigForm = this.fb.group({ + settings: [settings, []] + }); + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; + return this.widgetConfig; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts index d0b07e5359..166d1df15f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts @@ -61,6 +61,6 @@ export class DisplayColumnsPanelComponent { } public update() { - this.data.columnsUpdated(this.columns); + this.data.columnsUpdated(this.data.columns); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts index 12cd55f5b1..4998a9ab9c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-hierarchy-widget.component.ts @@ -448,6 +448,10 @@ export class EntitiesHierarchyWidgetComponent extends PageComponent implements O const subscriptionOptions: WidgetSubscriptionOptions = { type: widgetType.latest, datasources: [childrenDatasource], + pageSize: this.ctx.widgetConfig.pageSize, + useDashboardTimewindow: this.ctx.widgetConfig.useDashboardTimewindow, + dashboardTimewindow: this.ctx.dashboardTimewindow, + timeWindowConfig: this.ctx.widgetConfig.useDashboardTimewindow ? this.ctx.dashboardTimewindow : this.ctx.widgetConfig.timewindow, callbacks: { onSubscriptionMessage: (subscription, message) => { this.ctx.showToast(message.severity, message.message, undefined, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html index f832b1982e..e8fba081ee 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html @@ -24,8 +24,15 @@ {{ 'widgets.recent-dashboards.last' | translate }} {{ 'widgets.recent-dashboards.starred' | translate }} - {{ 'dashboard.add' | translate }} + + @if (hasDevice) { + {{ 'dashboard.add' | translate }} + } @else { + {{ 'dashboard.add' | translate }} + } + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts index 899ffa5aea..5dfc520856 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts @@ -48,6 +48,13 @@ import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { MatSort } from '@angular/material/sort'; import { DashboardInfo } from '@shared/models/dashboard.models'; import { DashboardAutocompleteComponent } from '@shared/components/dashboard-autocomplete.component'; +import { UtilsService } from '@core/services/utils.service'; +import { Datasource, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { formattedDataFormDatasourceData } from '@core/utils'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { EntityType } from '@shared/models/entity-type.models'; @Component({ selector: 'tb-recent-dashboards-widget', @@ -78,13 +85,16 @@ export class RecentDashboardsWidgetComponent extends PageComponent implements On starredDashboardValue = null; hasDashboardsAccess = true; + hasDevice = true; dirty = false; public customerId: string; private isFullscreenMode = getCurrentAuthState(this.store).forceFullscreen; + private subscription: IWidgetSubscription; constructor(protected store: Store, private cd: ChangeDetectorRef, + private utils: UtilsService, private userSettingService: UserSettingsService) { super(store); } @@ -96,6 +106,41 @@ export class RecentDashboardsWidgetComponent extends PageComponent implements On this.hasDashboardsAccess = [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER].includes(this.authUser.authority); if (this.hasDashboardsAccess) { this.reload(); + + if (window.location.pathname.startsWith('/home') && this.authUser.authority === Authority.TENANT_ADMIN) { + const ds: Datasource = { + type: DatasourceType.entityCount, + name: '', + entityFilter: { + entityType: EntityType.DEVICE, + type: AliasFilterType.entityType + }, + dataKeys: [this.utils.createKey({ name: 'count'}, DataKeyType.count)] + } + + const apiUsageSubscriptionOptions: WidgetSubscriptionOptions = { + datasources: [ds], + useDashboardTimewindow: false, + type: widgetType.latest, + callbacks: { + onDataUpdated: (subscription) => { + const data = formattedDataFormDatasourceData(subscription.data); + this.hasDevice = (data[0].count || 0) !== 0; + this.cd.detectChanges(); + } + } + }; + this.ctx.subscriptionApi.createSubscription(apiUsageSubscriptionOptions, true).subscribe((subscription) => { + this.subscription = subscription; + }); + } + } + } + + ngOnDestroy() { + super.ngOnDestroy(); + if (this.subscription) { + this.ctx.subscriptionApi.removeSubscription(this.subscription.id); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts new file mode 100644 index 0000000000..f7bae926bc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts @@ -0,0 +1,318 @@ +/// +/// 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 { + Component, + ElementRef, + Inject, + Injector, + Input, + OnInit, + Optional, + Type, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { + htmlContainerDefaultSettings, + HtmlContainerWidgetSettings, + HtmlContainerWidgetType, + WidgetContainerAngularFunction, + WidgetContainerPlainFunction +} from '@home/components/widget/lib/html/html-container-widget.models'; +import { hashCode, isNotEmptyStr, parseTbFunction } from '@core/utils'; +import { CompiledTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models'; +import { catchError, forkJoin, map, Observable, of, switchMap, throwError } from 'rxjs'; +import cssjs from '@core/css/css'; +import { SHARED_MODULE_TOKEN } from '@shared/components/tokens'; +import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; +import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; +import { ExceptionData } from '@shared/models/error.models'; +import { UtilsService } from '@core/services/utils.service'; +import { + flatModulesWithComponents, + ModulesWithComponents, + modulesWithComponentsToTypes, + ResourcesService +} from '@core/services/resources.service'; +import { MODULES_MAP } from '@shared/models/constants'; +import { IModulesMap } from '@modules/common/modules-map.models'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; + +@Component({ + selector: 'tb-html-container-widget', + template: '
' + + '@if (widgetErrorData) {
\n' + + ' \n' + + '
}', + styles: '.tb-widget-error {\n' + + ' display: flex;\n' + + ' align-items: center;\n' + + ' justify-content: center;\n' + + ' background: rgba(255, 255, 255, .5);\n' + + '\n' + + ' span {\n' + + ' color: #f00;\n' + + ' }\n' + + ' }', + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class HtmlContainerWidgetComponent implements OnInit { + + @ViewChild('container', {static: true}) + containerElmRef: ElementRef; + + @ViewChild('angularContainer', {static: true}) + angularContainer: TbAnchorComponent; + + @Input() + ctx: WidgetContext; + + private containerInstanceComponentType: Type; + + private settings: HtmlContainerWidgetSettings; + + widgetErrorData: ExceptionData; + + constructor(private elementRef: ElementRef, + @Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap, + @Inject(SHARED_MODULE_TOKEN) private sharedModule: Type, + @Inject(WIDGET_COMPONENTS_MODULE_TOKEN) private widgetComponentsModule: Type, + @Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type, + private dynamicComponentFactoryService: DynamicComponentFactoryService, + private utils: UtilsService, + private resources: ResourcesService) {} + + ngOnInit(): void { + this.settings = {...htmlContainerDefaultSettings, ...(this.ctx.settings || {})}; + this.loadWidgetResources().subscribe( + { + next: () => { + if (this.settings.type === HtmlContainerWidgetType.PLAIN) { + this.initPlain(); + } else if (this.settings.type === HtmlContainerWidgetType.ANGULAR) { + this.initAngular(); + } + }, + error: (e) => { + this.handleWidgetException(e); + } + } + ); + } + + private initPlain(): void { + try { + if (isNotEmptyStr(this.settings.css)) { + const cssParser = new cssjs(); + cssParser.testMode = false; + const namespace = 'html-container-' + hashCode(this.settings.css); + cssParser.cssPreviewNamespace = namespace; + cssParser.createStyleElement(namespace, this.settings.css); + $(this.elementRef.nativeElement).addClass(namespace); + } + if (isNotEmptyStr(this.settings.html)) { + $(this.containerElmRef.nativeElement).html(this.settings.html); + } + this.compileAndExecutePlainFunction(); + } catch (e) { + this.handleWidgetException(e); + } + } + + private compileAndExecutePlainFunction(): void { + if (isNotEmptyTbFunction(this.settings.js)) { + const jsFunction: Observable> = parseTbFunction(this.ctx.http, this.settings.js, ['ctx', 'container']); + jsFunction.subscribe({ + next: (containerFunction) => { + try { + containerFunction.execute(this.ctx, this.containerElmRef.nativeElement); + } catch (e) { + this.handleWidgetException(e); + } + }, + error: (e) => { + this.handleWidgetException(e); + } + }); + } + } + + private initAngular(): void { + this.loadAngularModules().subscribe( + { + next: (imports) => { + this.compileAngularFunction().subscribe( + { + next: (containerFunction) => { + try { + this.initAngularComponent(imports, containerFunction); + } catch (e) { + this.handleWidgetException(e); + } + }, + error: (e) => { + this.handleWidgetException(e); + } + } + ); + }, + error: (e) => { + this.handleWidgetException(e); + } + } + ); + } + + private compileAngularFunction(): Observable> { + if (isNotEmptyTbFunction(this.settings.js)) { + return parseTbFunction(this.ctx.http, this.settings.js, ['ctx']); + } else { + return of(null); + } + } + + private initAngularComponent(imports?: Type[], containerFunction?: CompiledTbFunction): void { + this.angularContainer.viewContainerRef.clear(); + const destroyContainerInstanceResources = this.destroyContainerInstanceResources.bind(this); + const template = this.settings.html || ''; + const styles: string[] = []; + if (isNotEmptyStr(this.settings.css)) { + styles.push(this.settings.css); + } + let compileModules = [this.sharedModule, this.widgetComponentsModule, this.homeComponentsModule]; + if (imports && imports.length) { + compileModules = compileModules.concat(imports); + } + const self = () => this; + this.dynamicComponentFactoryService.createDynamicComponent( + class TbContainerInstance { + ngOnInit(): void { + if (containerFunction) { + const instance = self(); + try { + containerFunction.apply(this, [instance.ctx]); + } catch (e) { + instance.handleWidgetException(e); + } + } + } + ngOnDestroy(): void { + destroyContainerInstanceResources(); + } + }, + template, + compileModules, + true, styles + ).subscribe({ + next: (componentType) => { + this.containerInstanceComponentType = componentType; + const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector}); + try { + this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, + {index: 0, injector}); + + } catch (error) { + this.handleWidgetException(error); + } + }, + error: (e) => { + this.handleWidgetException(e); + } + }); + } + + private destroyContainerInstanceResources() { + if (this.containerInstanceComponentType) { + this.dynamicComponentFactoryService.destroyDynamicComponent(this.containerInstanceComponentType); + this.containerInstanceComponentType = null; + } + } + + private handleWidgetException(e: any) { + console.error(e); + this.widgetErrorData = this.utils.processWidgetException(e); + this.ctx.detectChanges(); + } + + private loadWidgetResources(): Observable { + const resourceTasks: Observable[] = []; + this.settings.resources.filter(r => !r.isModule).forEach( + (resource) => { + resourceTasks.push( + this.resources.loadResource(resource.url).pipe( + catchError(() => of(`Failed to load widget resource: '${resource.url}'`)) + ) + ); + } + ); + if (resourceTasks.length) { + return forkJoin(resourceTasks).pipe( + switchMap(msgs => { + let errors: string[]; + if (msgs && msgs.length) { + errors = msgs.filter(msg => msg && msg.length > 0); + } + if (errors && errors.length) { + return throwError(() => new Error(errors.join('
'))); + } else { + return of(null); + } + } + )); + } else { + return of(null); + } + } + + private loadAngularModules(): Observable[]> { + const modulesTasks: Observable[] = []; + this.settings.resources.filter(r => r.isModule).forEach( + (resource) => { + modulesTasks.push( + this.resources.loadModulesWithComponents(resource.url, this.modulesMap).pipe( + catchError((e: Error) => of(e?.message ? e.message : `Failed to load widget resource module: '${resource.url}'`)) + ) + ); + } + ); + if (modulesTasks.length) { + return forkJoin(modulesTasks).pipe( + map(res => { + const msg = res.find(r => typeof r === 'string'); + if (msg) { + return msg as string; + } else { + const modulesWithComponentsList = res as ModulesWithComponents[]; + return flatModulesWithComponents(modulesWithComponentsList); + } + }), + switchMap(modulesWithComponentsList => { + if (typeof modulesWithComponentsList === 'string') { + return throwError(() => new Error(modulesWithComponentsList)); + } else { + const modules = modulesWithComponentsToTypes(modulesWithComponentsList); + return of(modules); + } + }) + ); + } else { + return of(null); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts new file mode 100644 index 0000000000..b974e15fb2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts @@ -0,0 +1,68 @@ +/// +/// 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 { TbFunction } from '@shared/models/js-function.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models'; +import { widgetContextCompletions } from '@shared/models/ace/widget-completion.models'; +import { WidgetResource } from '@shared/models/widget.models'; + +export enum HtmlContainerWidgetType { + PLAIN = 'PLAIN', + ANGULAR = 'ANGULAR' +} + +export interface HtmlContainerWidgetSettings { + type: HtmlContainerWidgetType; + html: string; + css: string; + js: TbFunction; + resources: WidgetResource[]; +} + +export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = { + type: HtmlContainerWidgetType.PLAIN, + html: '', + css: '', + js: '', + resources: [], +}; + +export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void; +export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void; + +const containerFunctionCompletions: TbEditorCompletions = { + ...{ + ctx: { + meta: 'argument', + type: widgetContextCompletions.ctx.type, + description: widgetContextCompletions.ctx.description, + children: widgetContextCompletions.ctx.children + } + } +}; + +export const AngularContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions); + +export const HTMLContainerFunctionEditorCompleter = new TbEditorCompleter( + {...containerFunctionCompletions, + container: { + meta: 'argument', + type: 'HTMLElement', + description: 'Container element of the widget' + }} +); + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts index 0b00c5970e..ba40af770c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts @@ -289,7 +289,7 @@ export default abstract class LeafletMap { } private toggleDrawMode(type: string) { - this.map.pm.Draw[type].toggle(); + (this.map.pm.Draw[type] as any).toggle(); } addEditControl() { @@ -373,7 +373,7 @@ export default abstract class LeafletMap { }, // @ts-ignore afterClick: (e, ctx) => { - this.map.pm.Draw[ctx.button._button.jsClass].toggle({ + (this.map.pm.Draw[ctx.button._button.jsClass] as any).toggle({ snappable: this.options.snappable, cursorMarker: true, allowSelfIntersection: false, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index bec45b32e0..dcaf63c2ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -100,8 +100,9 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem, _dsData: FormattedData[]): void { + protected doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void { this.updateCircleShape(data); + this.updateLabel(data, dsData); } protected addItemClass(clazz: string): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index df642a97cf..5c500335f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -105,8 +105,9 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem, _dsData: FormattedData[]): void { + protected doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void { this.updatePolygonShape(data); + this.updateLabel(data, dsData); } protected addItemClass(clazz: string): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts index 060a74aebd..541b6ab384 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts @@ -24,7 +24,7 @@ import { ViewChild, ViewEncapsulation } from '@angular/core'; -import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; + import { ImageService } from '@app/core/public-api'; import { AppState } from '@core/core.state'; import { AttributeService } from '@core/http/attribute.service'; @@ -63,8 +63,7 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On protected store: Store, private imageService: ImageService, private utils: UtilsService, - private attributeService: AttributeService, - private sanitizer: DomSanitizer + private attributeService: AttributeService ) { super(store); } @@ -115,8 +114,8 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On isLoading = false; singleDevice = true; updatePhoto = false; - previewPhoto: SafeUrl; - lastPhoto: SafeUrl; + previewPhoto: string; + lastPhoto: string; datasourceDetected = false; private mimeType: string; @@ -176,7 +175,7 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On private updateWidgetData(data: Array) { const keyData = data[0].data; if (keyData?.length && isString(keyData[0][1])) { - this.lastPhoto = keyData[0][1].startsWith('data:image/') ? this.sanitizer.bypassSecurityTrustUrl(keyData[0][1]) : keyData[0][1]; + this.lastPhoto = keyData[0][1]; } } @@ -309,7 +308,7 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On const file = new File([blob], fileName, { type: this.mimeType }); return this.imageService.uploadImage(file, fileName); }), - map((imageInfo) => + map((imageInfo) => this.settings.usePublicGalleryLink ? imageInfo.publicLink : imageInfo.link ) ); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts index 4836cc3804..6b8fcad4fe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts @@ -28,7 +28,7 @@ import { Circle, Effect, Element, G, Gradient, Path, Runner, Svg, Text, Timeline import '@svgdotjs/svg.filter.js'; import tinycolor from 'tinycolor2'; import { WidgetContext } from '@home/models/widget-component.models'; -import { Observable, of, shareReplay } from 'rxjs'; +import { from, Observable, of, shareReplay } from 'rxjs'; import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; import { catchError, map, take } from 'rxjs/operators'; import { MatIconRegistry } from '@angular/material/icon'; @@ -392,7 +392,15 @@ export abstract class PowerButtonShape { tspan.attr({ 'dominant-baseline': 'hanging' }); - return of(textElement); + return from(document.fonts.ready).pipe( + map(() => { + const iconGroup = this.svgShape.group(); + textElement.addTo(iconGroup); + const box = iconGroup.bbox(); + iconGroup.translate(-box.cx, -box.cy); + return iconGroup; + }) + ); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html index df90a75dba..334ace9886 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html @@ -214,7 +214,7 @@ - + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.ts index 8970569cf3..24d49bc608 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.ts @@ -66,7 +66,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; export class DynamicFormSelectItemsComponent implements ControlValueAccessor, OnInit, Validator { @HostBinding('style.display') styleDisplay = 'flex'; - @HostBinding('style.overflow') styleOverflow = 'hidden'; @ViewChildren(DynamicFormSelectItemRowComponent) selectItemRows: QueryList; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html new file mode 100644 index 0000000000..a72f28e70e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html @@ -0,0 +1,128 @@ + + +
+
+ + {{ 'widgets.html-container.type-plain' | translate }} + {{ 'widgets.html-container.type-angular' | translate }} + +
+
+
+ +
+
+ + + +
{{ 'widgets.html-container.resources' | translate }}
+
+
+ @if (resourcesFormArray.length) { + @for (resourceControl of resourcesControls; track resourceControl; let i = $index) { +
+ + + @if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) { + + {{ 'widget.resource-is-extension' | translate }} + + } + +
+ } + } @else { + widgets.html-container.no-resources + } +
+ +
+
+
+ + + + + + + + + @if (!fullscreen) { + + + + } +
+
+ @if (fullscreen) { + + } +
+
+
+
+
+ + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss new file mode 100644 index 0000000000..c2db82139d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss @@ -0,0 +1,128 @@ +/** + * 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. + */ + +.tb-html-container-settings { + height: 100%; +} + +.tb-html-container-settings .tb-html-container-settings-panel, .tb-html-container-settings-panel { + position: relative; + background: #fff; + .mat-mdc-tab-body-wrapper { + position: relative; + top: 0; + flex: 1; + } + .tb-action-expand-button { + position: absolute; + top: 4px; + right: 0; + z-index: 2; + } + .gutter { + display: none; + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; + &.gutter-horizontal { + cursor: col-resize; + background-image: url("../../../../../../../../../assets/split.js/grips/vertical.png"); + } + } + .tb-js-func { + &:not(.tb-fullscreen) { + &.tb-hide-brackets { + padding-bottom: 0; + } + } + } + .tb-html { + position: relative; + &:not(.tb-fullscreen) { + padding-bottom: 0; + } + .tb-html-toolbar { + position: absolute; + top: 0; + right: 8px; + z-index: 8; + .tb-title { + display: none; + } + } + .tb-html-content-panel { + border-top: none; + height: 100%; + } + } + .tb-css { + position: relative; + &:not(.tb-fullscreen) { + .tb-css-content-panel { + margin: 0; + } + } + .tb-css-toolbar { + position: absolute; + top: 0; + right: 8px; + z-index: 8; + .tb-title { + display: none; + } + } + .tb-css-content-panel { + border-top: none; + height: 100%; + } + } + &.tb-fullscreen { + padding: 8px; + gap: 8px; + .tb-action-expand-button { + position: relative; + top: 0; + right: 0; + } + .gutter { + display: block; + } + .tb-content { + border: 1px solid #c0c0c0; + .tb-html { + .tb-html-content-panel { + border: none; + } + } + .tb-css { + .tb-css-content-panel { + border: none; + } + } + .tb-js-func { + padding-top: 8px; + .tb-js-func-toolbar { + padding: 0 5px; + } + .tb-js-func-panel { + border-left: none; + border-right: none; + border-bottom: none; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts new file mode 100644 index 0000000000..121362ef57 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts @@ -0,0 +1,237 @@ +/// +/// 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 { + AfterViewInit, + Component, + DestroyRef, + ElementRef, + forwardRef, + HostBinding, + Input, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { WidgetResource } from '@shared/models/widget.models'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { + AngularContainerFunctionEditorCompleter, + HTMLContainerFunctionEditorCompleter, + HtmlContainerWidgetSettings, + HtmlContainerWidgetType +} from '@home/components/widget/lib/html/html-container-widget.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isJSResource } from '@shared/models/resource.models'; +import { WidgetService } from '@core/http/widget.service'; + +@Component({ + selector: 'tb-html-container-settings', + templateUrl: './html-container-settings.component.html', + styleUrls: ['./html-container-settings.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => HtmlContainerSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => HtmlContainerSettingsComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class HtmlContainerSettingsComponent implements OnInit, AfterViewInit, ControlValueAccessor, Validator { + + HtmlContainerWidgetType = HtmlContainerWidgetType; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + get containerFunctionEditorCompleter() { + return this.htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR + ? AngularContainerFunctionEditorCompleter + : HTMLContainerFunctionEditorCompleter; + } + + @HostBinding('class') + hostClass = 'tb-html-container-settings'; + + @ViewChild('leftPanel', { read: ElementRef }) + leftPanelElmRef!: ElementRef; + + @ViewChild('rightPanel', { read: ElementRef }) + rightPanelElmRef!: ElementRef; + + @Input() + disabled: boolean; + + fullscreen = false; + + tabsAnimationDuration = '500ms'; + + htmlContainerSettingsForm: UntypedFormGroup; + + private modelValue: HtmlContainerWidgetSettings; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + get resourcesFormArray(): UntypedFormArray { + return this.htmlContainerSettingsForm.get('resources') as UntypedFormArray; + } + + get resourcesControls(): UntypedFormGroup[] { + return this.resourcesFormArray.controls as UntypedFormGroup[]; + } + + ngOnInit(): void { + this.htmlContainerSettingsForm = this.fb.group({ + type: [null, []], + html: [null, []], + css: [null, []], + js: [null, []], + resources: this.fb.array([]) + }); + this.htmlContainerSettingsForm.get('type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateResources()); + this.htmlContainerSettingsForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + ngAfterViewInit(): void { + if (this.leftPanelElmRef && this.rightPanelElmRef) { + this.initSplitLayout(this.leftPanelElmRef.nativeElement, + this.rightPanelElmRef.nativeElement); + } + } + + private initSplitLayout(leftPanel: any, rightPanel: any) { + Split([leftPanel, rightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.htmlContainerSettingsForm.disable({emitEvent: false}); + } else { + this.htmlContainerSettingsForm.enable({emitEvent: false}); + } + } + + writeValue(value: HtmlContainerWidgetSettings): void { + this.modelValue = value; + this.htmlContainerSettingsForm.get('type').patchValue(value.type, {emitEvent: false}); + this.htmlContainerSettingsForm.get('html').patchValue(value.html, {emitEvent: false}); + this.htmlContainerSettingsForm.get('css').patchValue(value.css, {emitEvent: false}); + this.htmlContainerSettingsForm.get('js').patchValue(value.js, {emitEvent: false}); + this.resourcesFormArray.clear({emitEvent: false}); + value.resources.forEach(r => { + this.resourcesFormArray.push(this.buildResourceFormGroup(r), {emitEvent: false}); + }); + } + + validate(_c: UntypedFormControl) { + return this.htmlContainerSettingsForm.valid ? null : { + htmlContainerSettings: { + valid: false, + } + }; + } + + addResource() { + const newResource: WidgetResource = { + url: '', + isModule: false + }; + this.resourcesFormArray.push(this.buildResourceFormGroup(newResource)); + } + + removeResource(index: number) { + this.resourcesFormArray.removeAt(index); + } + + toggleFullScreen(): void { + this.fullscreen = !this.fullscreen; + this.tabsAnimationDuration = '0ms'; + setTimeout(() => { + this.tabsAnimationDuration = '500ms'; + }); + } + + private propagateChange = (_v: any) => { }; + + private updateModel() { + this.modelValue = this.htmlContainerSettingsForm.value; + this.propagateChange(this.modelValue); + } + + private updateResources() { + if (this.htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.PLAIN) { + const resources: WidgetResource[] = this.resourcesFormArray.value; + const filtered = resources.filter(r => !isJSResource(r.url)); + let updated = filtered.length !== resources.length; + filtered.forEach((r) => { + if (r.isModule) { + r.isModule = false; + updated = true; + } + }); + if (updated) { + this.resourcesFormArray.clear(); + filtered.forEach(r => { + this.resourcesFormArray.push(this.buildResourceFormGroup(r)); + }); + } + } + } + + private buildResourceFormGroup(resource: WidgetResource): UntypedFormGroup { + return this.fb.group({ + url: [resource.url, [Validators.required]], + isModule: [resource.isModule] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 2a9d05b57b..8d6e96332f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -267,6 +267,9 @@ import { import { ShapeFillStripeSettingsPanelComponent } from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component'; +import { + HtmlContainerSettingsComponent +} from '@home/components/widget/lib/settings/common/html/html-container-settings.component'; import { AxisScaleRowComponent } from './axis-scale-row.component'; @NgModule({ @@ -374,6 +377,7 @@ import { AxisScaleRowComponent } from './axis-scale-row.component'; DataKeyConfigDialogComponent, DataKeyConfigComponent, WidgetSettingsComponent, + HtmlContainerSettingsComponent, AxisScaleRowComponent ], imports: [ @@ -456,6 +460,7 @@ import { AxisScaleRowComponent } from './axis-scale-row.component'; DataKeyConfigDialogComponent, DataKeyConfigComponent, WidgetSettingsComponent, + HtmlContainerSettingsComponent, AxisScaleRowComponent ], providers: [ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html index 65f2044a7b..401c5d258a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
{{definedDirectiveError}}
+ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts new file mode 100644 index 0000000000..200b17d66d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts @@ -0,0 +1,65 @@ +/// +/// 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 { Component, HostBinding } from '@angular/core'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { htmlContainerDefaultSettings } from '@home/components/widget/lib/html/html-container-widget.models'; + +@Component({ + selector: 'tb-html-container-widget-settings', + templateUrl: './html-container-widget-settings.component.html', + styleUrls: [], + standalone: false +}) +export class HtmlContainerWidgetSettingsComponent extends WidgetSettingsComponent { + + @HostBinding('height') + hostHeight = '100%'; + + htmlContainerWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.htmlContainerWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return htmlContainerDefaultSettings; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.htmlContainerWidgetSettingsForm = this.fb.group({ + htmlContainerSettings: [settings.htmlContainerSettings, []] + }); + } + + protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { + return { + htmlContainerSettings: settings + }; + } + + protected prepareOutputSettings(settings: any): WidgetSettings { + return settings.htmlContainerSettings; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index 3d8c564b67..387737a461 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -375,6 +375,9 @@ import { ValueStepperWidgetSettingsComponent } from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component'; import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component'; +import { + HtmlContainerWidgetSettingsComponent +} from '@home/components/widget/lib/settings/html/html-container-widget-settings.component'; import { ApiUsageWidgetSettingsComponent } from "@home/components/widget/lib/settings/cards/api-usage-widget-settings.component"; @@ -515,6 +518,7 @@ import { UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, MapWidgetSettingsComponent, + HtmlContainerWidgetSettingsComponent, ApiUsageWidgetSettingsComponent, ApiUsageDataKeyRowComponent ], @@ -656,6 +660,7 @@ import { UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, MapWidgetSettingsComponent, + HtmlContainerWidgetSettingsComponent, ApiUsageWidgetSettingsComponent ] }) diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index dc0161d0e3..dd81f1e853 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -94,6 +94,7 @@ import { SelectMapEntityPanelComponent } from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; +import { HtmlContainerWidgetComponent } from '@home/components/widget/lib/html/html-container-widget.component'; import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-usage-widget.component"; @NgModule({ @@ -153,6 +154,7 @@ import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-u SelectMapEntityPanelComponent, MapTimelinePanelComponent, MapWidgetComponent, + HtmlContainerWidgetComponent, ApiUsageWidgetComponent ], imports: [ @@ -217,6 +219,7 @@ import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-u NotificationTypeFilterPanelComponent, ScadaSymbolWidgetComponent, MapWidgetComponent, + HtmlContainerWidgetComponent, ApiUsageWidgetComponent ], providers: [ diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 166c32a7fd..a321ec81a5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -322,7 +322,7 @@
-
+
.mat-content { + height: 100%; padding-top: 8px; @media #{$mat-xs} { padding-left: 8px; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index 443c801633..b04fad8caf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -334,6 +334,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, if (customHeaderActions$.length) { forkJoin(customHeaderActions$).subscribe((customHeaderActions) => { this.widgetContext.customHeaderActions.push(...customHeaderActions); + this.dashboardWidget.updateParamsFromData(true); }); } diff --git a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts index 913e754fb7..336ac035da 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts @@ -175,7 +175,7 @@ export class EntityTableConfig, P extends PageLink = P selectionEnabled = true; searchEnabled = true; addEnabled = true; - addAsTextButton = false; + addAsTextButton = true; entitiesDeleteEnabled = true; detailsPanelEnabled = true; hideDetailsTabsOnEdit = true; 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/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts index 2a73488d7e..7c78bd7c02 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -47,7 +47,6 @@ export class AiModelsTableConfigResolver { ) { this.config.selectionEnabled = true; this.config.entityType = EntityType.AI_MODEL; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.detailsPanelEnabled = false; this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL); 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/audit-log/audit-log-routing.module.ts b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts index 2a4cd70c90..b9943eb41f 100644 --- a/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts @@ -25,7 +25,7 @@ export const auditLogsRoutes: Routes = [ path: 'auditLogs', component: AuditLogTableComponent, data: { - auth: [Authority.TENANT_ADMIN], + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], title: 'audit-log.audit-logs', breadcrumb: { menuId: MenuId.audit_log 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 b3e1ee2883..3853a0f7e9 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 @@ -27,6 +27,7 @@ label="{{ 'attribute.latest-telemetry' | translate }}" #telemetryTab="matTab"> diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts index 13698ac4a5..274edf5933 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts @@ -57,7 +57,6 @@ export class MobileAppTableConfigResolver { ) { this.config.selectionEnabled = false; this.config.entityType = EntityType.MOBILE_APP; - this.config.addAsTextButton = true; this.config.entitiesDeleteEnabled = false; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.MOBILE_APP); diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts index 69be801980..ba20624852 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts @@ -63,7 +63,6 @@ export class MobileBundleTableConfigResolver { ) { this.config.selectionEnabled = false; this.config.entityType = EntityType.MOBILE_APP_BUNDLE; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.detailsPanelEnabled = false; this.config.entityTranslations = entityTypeTranslations.get(EntityType.MOBILE_APP_BUNDLE); diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts index c2fc08fb7c..8e729e94ef 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts @@ -48,7 +48,6 @@ export class RecipientTableConfigResolver { this.config.entityType = EntityType.NOTIFICATION_TARGET; this.config.detailsPanelEnabled = false; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_TARGET); diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts index 066db92c0a..f71197ae95 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts @@ -49,7 +49,6 @@ export class RuleTableConfigResolver { this.config.entityType = EntityType.NOTIFICATION_RULE; this.config.detailsPanelEnabled = false; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_RULE); diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts index 0ee0b921f4..b1bc3eef3e 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts @@ -47,7 +47,6 @@ export class TemplateTableConfigResolver { this.config.entityType = EntityType.NOTIFICATION_TEMPLATE; this.config.detailsPanelEnabled = false; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_TEMPLATE); diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html index dcad41d86a..b24e5e247a 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html @@ -31,4 +31,8 @@ [entityName]="entity.name"> + + + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html index 0469adca74..a6eec3ec29 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -23,6 +23,7 @@ *ngIf="displayEntityTypeSelect" [showLabel]="true" [required]="required" + subscriptSizing="dynamic" [useAliasEntityTypes]="useAliasEntityTypes" [allowedEntityTypes]="allowedEntityTypes" [filterAllowedEntityTypes]="filterAllowedEntityTypes" diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html index 65e3615887..b3f4fa5300 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html @@ -24,6 +24,9 @@ {{ displayEntityTypeFn(type) }} + @if (!inlineField && subscriptSizing === 'dynamic') { + + } {{ 'entity.type-required' | translate }} diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.html b/ui-ngx/src/app/shared/components/image/image-gallery.component.html index f442556b18..b31dc16920 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery.component.html +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.html @@ -74,10 +74,10 @@ mdi:file-import