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 9e6635dfca..8e5e6b2564 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT thingsboard application 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..3140b4f253 --- /dev/null +++ b/application/src/main/data/upgrade/lts/schema_update.sql @@ -0,0 +1,19 @@ +-- +-- 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. 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 a9692f4b9d..925b60de76 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -35,6 +35,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; @@ -684,6 +685,10 @@ public class ActorSystemContext { @Getter private MqttClientSettings mqttClientSettings; + @Autowired(required = false) + @Getter + private TbHttpClientSettings tbHttpClientSettings; + @Getter @Setter private TbActorSystem actorSystem; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 66a15a651e..3a572503d6 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; @@ -1062,6 +1063,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/TbHttpClientSettingsComponent.java b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java new file mode 100644 index 0000000000..a5ad4e8742 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; + +@TbRuleEngineComponent +@Component +public class TbHttpClientSettingsComponent implements TbHttpClientSettings { + + @Value("${actors.rule.external.http_client.max_parallel_requests:0}") + private int maxParallelRequests; + + @Value("${actors.rule.external.http_client.max_pending_requests:0}") + private int maxPendingRequests; + + @Value("${actors.rule.external.http_client.pool_max_connections:0}") + private int poolMaxConnections; + + @Override + public int getMaxParallelRequests() { + return maxParallelRequests; + } + + @Override + public int getMaxPendingRequests() { + return maxPendingRequests; + } + + @Override + public int getPoolMaxConnections() { + return poolMaxConnections; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index ae8305c070..8a113fb424 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; +import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; @@ -77,6 +78,7 @@ public class AlarmCommentController extends BaseController { AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); alarmComment.setAlarmId(alarmId); + alarmComment.setType(AlarmCommentType.OTHER); return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); } diff --git a/application/src/main/java/org/thingsboard/server/controller/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/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/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 1eba2c4549..fc54b4b0db 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 @@ -19,6 +19,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.script.api.tbel.TbelInvokeService; @@ -29,18 +30,23 @@ 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.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.usagerecord.ApiLimitService; import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; @Service @Slf4j @@ -183,6 +189,85 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField); } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictTenantCfs(event.getTenantId()); + } + break; + case DEVICE, ASSET, 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 if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + 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.getEntityId())); + } + 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.getCalculatedFieldId())); + 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.getCalculatedFieldId()))); + } + 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/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index 364fcb47dd..c7203a0a07 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -135,6 +135,10 @@ public class InstallScripts { return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); } + public Path getWidgetBundlesDir() { + return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); + } + public String getDataDir() { if (!StringUtils.isEmpty(dataDir)) { if (!Paths.get(this.dataDir).toFile().isDirectory()) { @@ -207,7 +211,7 @@ public class InstallScripts { public void loadSystemWidgets() { log.info("Loading system widgets"); Map widgetsBundlesMap = new HashMap<>(); - Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); + Path widgetBundlesDir = getWidgetBundlesDir(); try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) { dirStream.forEach( path -> { diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 2babc3feeb..3932aa9dcf 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -54,6 +54,7 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService public void upgradeDatabase() { log.info("Updating schema..."); loadSql(getSchemaUpdateFile("basic")); + loadSql(getSchemaUpdateFile("lts")); log.info("Schema updated."); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index 28fa68d803..e0a9917509 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -143,6 +148,25 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + Set toRemove = assetProfilesMap.values().stream() + .filter(assetProfile -> assetProfile.getTenantId().equals(tenantId)) + .map(AssetProfile::getId) + .collect(Collectors.toSet()); + assetProfilesMap.keySet().removeAll(toRemove); + assetsMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); + profileListeners.remove(tenantId); + assetProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(AssetProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 6b356adf94..4729a8c118 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -143,6 +148,25 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + Set toRemove = deviceProfilesMap.values().stream() + .filter(deviceProfile -> deviceProfile.getTenantId().equals(tenantId)) + .map(DeviceProfile::getId) + .collect(Collectors.toSet()); + deviceProfilesMap.keySet().removeAll(toRemove); + devicesMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); + profileListeners.remove(tenantId); + deviceProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(DeviceProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index 60e60b5444..ba4ab74f37 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 @@ -90,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..de2e65723e 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 @@ -179,9 +179,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService consumer = requests.remove(requestId); if (consumer != null) { - consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), restApiCallResponseMsg.getResponse(), TbMsgCallback.EMPTY)); + consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), TbMsgCallback.EMPTY)); } else { log.trace("[{}] Unknown or stale rest api call response received", requestId); } diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java index d80961923d..a256888107 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java +++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java @@ -28,7 +28,10 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; @@ -40,7 +43,12 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @@ -48,7 +56,7 @@ import java.util.stream.Stream; /** * Runs at application startup and applies no-downtime data updates - * when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1). + * when the package version increases within the same LTS family (e.g., 4.3.0.0 -> 4.3.1.0 or 4.3.0.0 -> 4.3.0.1). */ @Slf4j @Component @@ -64,6 +72,8 @@ public class SystemPatchApplier { private final InstallScripts installScripts; private final DatabaseSchemaSettingsService schemaSettingsService; private final WidgetTypeService widgetTypeService; + private final WidgetsBundleService widgetsBundleService; + private final ImageService imageService; @PostConstruct private void init() { @@ -91,11 +101,19 @@ public class SystemPatchApplier { } try { + updateLtsSqlSchema(); + updateSqlViews(); log.info("Updated sql database views"); - int updated = updateWidgetTypes(); - log.info("Updated {} widget types", updated); + WidgetTypeStats widgetStats = updateWidgetTypes(); + log.info("System widget types: {} created, {} updated", widgetStats.created(), widgetStats.updated()); + + int updatedBundles = updateWidgetBundles(); + log.info("System widget bundles: {} updated", updatedBundles); + + int createdImages = createMissingSystemImages(); + log.info("Created {} new system images", createdImages); schemaSettingsService.updateSchemaVersion(); log.info("System data patch update completed successfully"); @@ -119,17 +137,37 @@ public class SystemPatchApplier { return false; } - if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) { + if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) { return false; } - log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); + log.info("Version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); return true; } - private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) { - return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor - && packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch; + private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) { + if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) { + return false; + } + if (packageVersion.maintenance != dbVersion.maintenance) { + return packageVersion.maintenance > dbVersion.maintenance; + } + return packageVersion.patch > dbVersion.patch; + } + + private void updateLtsSqlSchema() { + Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql"); + if (!Files.exists(sqlFile)) { + log.trace("LTS schema update file does not exist: {}", sqlFile); + return; + } + try { + String sql = Files.readString(sqlFile); + jdbcTemplate.execute(sql); + log.info("Applied LTS SQL schema update from {}", sqlFile); + } catch (IOException e) { + throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e); + } } private void updateSqlViews() { @@ -142,20 +180,24 @@ public class SystemPatchApplier { } } - private int updateWidgetTypes() { + private WidgetTypeStats updateWidgetTypes() { + AtomicInteger created = new AtomicInteger(); AtomicInteger updated = new AtomicInteger(); Path widgetTypesDir = installScripts.getWidgetTypesDir(); if (!Files.exists(widgetTypesDir)) { log.trace("Widget types directory does not exist: {}", widgetTypesDir); - return 0; + return new WidgetTypeStats(0, 0); } try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { dirStream.forEach( path -> { try { - if (updateWidgetTypeFromFile(path)) { + WidgetTypeChange change = updateWidgetTypeFromFile(path); + if (change == WidgetTypeChange.CREATED) { + created.incrementAndGet(); + } else if (change == WidgetTypeChange.UPDATED) { updated.incrementAndGet(); } } catch (Exception e) { @@ -166,18 +208,26 @@ public class SystemPatchApplier { ); } - return updated.get(); + return new WidgetTypeStats(created.get(), updated.get()); } - private boolean updateWidgetTypeFromFile(Path filePath) { + private WidgetTypeChange updateWidgetTypeFromFile(Path filePath) { JsonNode json = JacksonUtil.toJsonNode(filePath.toFile()); WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class); + return saveOrUpdateSystemWidgetType(fileWidgetType); + } + + private WidgetTypeChange saveOrUpdateSystemWidgetType(WidgetTypeDetails fileWidgetType) { String fqn = fileWidgetType.getFqn(); + if (fqn == null || fqn.isBlank()) { + throw new RuntimeException("Widget type fqn is missing or blank: " + fileWidgetType.getName()); + } WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn); if (existingWidgetType == null) { - // We expect only update here, so it's probably never happening, but for test purpose leave it like this: - throw new RuntimeException("Widget type not found: " + fqn); + widgetTypeService.saveWidgetType(fileWidgetType); + log.trace("Created widget type: {}", fqn); + return WidgetTypeChange.CREATED; } if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) { existingWidgetType.setDescription(fileWidgetType.getDescription()); @@ -185,11 +235,128 @@ public class SystemPatchApplier { existingWidgetType.setDescriptor(fileWidgetType.getDescriptor()); widgetTypeService.saveWidgetType(existingWidgetType); log.trace("Updated widget type: {}", fqn); - return true; + return WidgetTypeChange.UPDATED; } log.trace("Widget type unchanged: {}", fqn); - return false; + return WidgetTypeChange.UNCHANGED; + } + + private int updateWidgetBundles() { + AtomicInteger updated = new AtomicInteger(); + Path widgetBundlesDir = installScripts.getWidgetBundlesDir(); + + if (!Files.exists(widgetBundlesDir)) { + log.trace("Widget bundles directory does not exist: {}", widgetBundlesDir); + return 0; + } + + try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { + dirStream.forEach(path -> { + try { + if (processWidgetBundleFile(path)) { + updated.incrementAndGet(); + } + } catch (Exception e) { + log.error("Unable to process widgets bundle from json: [{}]", path); + throw new RuntimeException("Unable to process widgets bundle from json", e); + } + }); + } + + return updated.get(); + } + + private boolean processWidgetBundleFile(Path filePath) { + JsonNode bundleJson = JacksonUtil.toJsonNode(filePath.toFile()); + if (bundleJson == null || !bundleJson.has("widgetsBundle")) { + throw new RuntimeException("Invalid widgets bundle json: " + filePath); + } + WidgetsBundle fileBundle = JacksonUtil.treeToValue(bundleJson.get("widgetsBundle"), WidgetsBundle.class); + String alias = fileBundle.getAlias(); + if (alias == null || alias.isBlank()) { + throw new RuntimeException("Widgets bundle alias is missing or blank: " + filePath); + } + + WidgetsBundle existingBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias); + if (existingBundle == null) { + log.warn("Widgets bundle '{}' not found in DB; bundle creation is not supported by the patch applier, skipping.", alias); + return false; + } + + if (bundleJson.has("widgetTypes")) { + throw new RuntimeException("Inline widgetTypes in bundle JSON are not supported by the patch applier; " + + "place widget definitions in widget_types/*.json and reference them via widgetTypeFqns: " + filePath); + } + List fileWidgetFqns = new ArrayList<>(); + if (bundleJson.has("widgetTypeFqns")) { + bundleJson.get("widgetTypeFqns").forEach(fqnJson -> fileWidgetFqns.add(fqnJson.asText())); + } + + boolean changed = false; + if (isWidgetsBundleChanged(existingBundle, fileBundle)) { + existingBundle.setTitle(fileBundle.getTitle()); + existingBundle.setDescription(fileBundle.getDescription()); + existingBundle.setImage(fileBundle.getImage()); + existingBundle.setOrder(fileBundle.getOrder()); + existingBundle.setScada(fileBundle.isScada()); + widgetsBundleService.saveWidgetsBundle(existingBundle); + log.trace("Updated widgets bundle metadata: {}", alias); + changed = true; + } + + List existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()); + LinkedHashSet mergedFqns = new LinkedHashSet<>(existingFqns); + if (mergedFqns.addAll(fileWidgetFqns)) { + widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, existingBundle.getId(), new ArrayList<>(mergedFqns)); + log.trace("Linked {} new widget fqn(s) to bundle: {}", mergedFqns.size() - existingFqns.size(), alias); + changed = true; + } + return changed; + } + + private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) { + // Image is intentionally NOT compared: the file always carries a base64 data URI, while the DB stores + // the system-image URL produced by ImageService.replaceBase64WithImageUrl on save. A naive string compare + // would always report a diff and re-save every system bundle on every patch run. Image content changes + // are out of scope for the patch applier — full reinstall covers them. + return !Objects.equals(existing.getTitle(), file.getTitle()) + || !Objects.equals(existing.getDescription(), file.getDescription()) + || !Objects.equals(existing.getOrder(), file.getOrder()) + || existing.isScada() != file.isScada(); + } + + private int createMissingSystemImages() { + AtomicInteger created = new AtomicInteger(); + Path imagesDir = Paths.get(installScripts.getDataDir(), InstallScripts.RESOURCES_DIR, "images"); + + if (!Files.exists(imagesDir)) { + log.warn("System images directory does not exist: {}", imagesDir); + return 0; + } + + Set existingKeys = imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + + try (Stream dirStream = listDir(imagesDir).filter(Files::isRegularFile)) { + dirStream.forEach(path -> { + String resourceKey = path.getFileName().toString(); + if (existingKeys.contains(resourceKey)) { + log.trace("System image already exists, skipping: {}", resourceKey); + return; + } + try { + byte[] data = Files.readAllBytes(path); + imageService.createOrUpdateSystemImage(resourceKey, data); + created.incrementAndGet(); + log.trace("Created system image: {}", resourceKey); + } catch (Exception e) { + log.error("Unable to create system image from file: [{}]", path); + throw new RuntimeException("Unable to create system image " + resourceKey, e); + } + }); + } + + return created.get(); } private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) { @@ -287,4 +454,8 @@ public class SystemPatchApplier { public record VersionInfo(int major, int minor, int maintenance, int patch) {} + public record WidgetTypeStats(int created, int updated) {} + + private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED } + } diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index 4b1a81dd2d..27a8ca275c 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -144,7 +144,7 @@ public class DefaultWebSocketService implements WebSocketService { private final ConcurrentMap> tenantSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> customerSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> regularUserSubscriptionsMap = new ConcurrentHashMap<>(); - private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); + private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> sessionCmdMap = new ConcurrentHashMap<>(); private ExecutorService executor; @@ -315,7 +315,7 @@ public class DefaultWebSocketService implements WebSocketService { } } - private void processSessionClose(WebSocketSessionRef sessionRef) { + void processSessionClose(WebSocketSessionRef sessionRef) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration != null) { String sessionId = "[" + sessionRef.getSessionId() + "]"; @@ -340,7 +340,7 @@ public class DefaultWebSocketService implements WebSocketService { } } if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { publicUserSessions.removeIf(subId -> subId.startsWith(sessionId)); } @@ -349,7 +349,7 @@ public class DefaultWebSocketService implements WebSocketService { } } - private boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) { + boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration == null) return true; @@ -401,9 +401,11 @@ public class DefaultWebSocketService implements WebSocketService { } } if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { - if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { + if (cmd.isUnsubscribe()) { + publicUserSessions.remove(subId); + } else if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { publicUserSessions.add(subId); } else { log.info("[{}][{}][{}] Failed to start subscription. Max public user subscriptions limit reached" diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 81b56aff28..2c24caa1f2 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: @@ -446,6 +454,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}" @@ -567,6 +576,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}" @@ -1394,6 +1415,15 @@ transport: branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.0.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: @@ -1530,7 +1560,7 @@ device: pem_cert_file: "${DEVICE_CONNECTIVITY_COAPS_CA_ROOT_CERT:cafile.pem}" gateway: # The docker tag for thingsboard/tb-gateway image used in docker-compose file for gateway launch - image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.7-stable}" + image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}" # Edges parameters edges: @@ -1659,8 +1689,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/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 b87473f6e3..ba86d60852 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; @@ -223,6 +225,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) @@ -356,6 +370,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/DeviceConnectivityControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java index 7d78877b9e..9a80ad2e25 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java @@ -95,7 +95,7 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest { private DeviceProfileId mqttDeviceProfileId; private DeviceProfileId coapDeviceProfileId; - @Value("${device.connectivity.gateway.image_version:3.7-stable}") + @Value("${device.connectivity.gateway.image_version:3.8-stable}") private String gatewayImageVersion; @Before @@ -321,9 +321,10 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest { "\n" + " # Environment variables\n" + " environment:\n" + - " - host=host.docker.internal\n" + - " - port=1883\n" + - " - accessToken=" + credentials.getCredentialsId() + "\n" + + " - TB_GW_HOST=host.docker.internal\n" + + " - TB_GW_PORT=1883\n" + + " - TB_GW_SECURITY_TYPE=accessToken\n" + + " - TB_GW_ACCESS_TOKEN=" + credentials.getCredentialsId() + "\n" + "\n" + " # Volumes bind\n" + " volumes:\n" + 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 225757e451..7d88edd73f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java @@ -54,11 +54,13 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.device.DeviceProfileDao; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.awaitility.Awaitility; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -1028,11 +1030,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/RuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java index f8cd8e0f33..a485c02bb8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java @@ -405,4 +405,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/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/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index 6b3bffc883..2ec67a7886 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -57,7 +57,12 @@ public class UserEdgeTest extends AbstractEdgeTest { User savedTenantAdmin = createUser(newTenantAdmin, "tenant"); Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) 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); Optional userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); Assert.assertTrue(userUpdateMsgOpt.isPresent()); UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get(); diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java new file mode 100644 index 0000000000..fb9807a2a8 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java @@ -0,0 +1,144 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ai; + +import com.google.cloud.vertexai.api.GenerationConfig; +import dev.langchain4j.model.chat.ChatModel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ResourceLock("SsrfProtectionValidator") +class Langchain4jChatModelConfigurerImplTest { + + private static final String TEST_SERVICE_ACCOUNT_KEY = """ + { + "type": "service_account", + "project_id": "test-project", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDNrHph/y7zyxIg\\ncmYYeOD8mFg9KraK71n84ffTQyVrl4HzlQgRIz5m4vM2rV5zjVLFi0xlAPT/iq/5\\nbh2zA4iXI0dEsR901nVjcL182t/GRYbKen53ZiuScBxBoCZPXW16Md+Yk8nMdNUb\\n4LoIRGZq64bjsJ+vh3Aa2gdGUHpDyebIXlXbF8ehWmEhgUsL7XjL0PkJ4lt2UMG+\\nx1j2Or25rqJmfc5M9kbxvINtdvSRTPiMOIXX00fCDZjQdd18RBVHOxraGxDgQpmv\\nk4qjFEPqGr0YTsa5dI8fz+4DqJpEi3rancRiTKM/KUwYLGnPSD07XqGfiDA8npVm\\nj4N62LhnAgMBAAECggEADbFfH87DRk7YQO8XgKdCOf7oglX+0NwjjmmlkVvwjgEI\\nZqT0ObPcz9u/MSjfV2vAEs/LK773ELD1NfLQqQjiBfHpfkIZOTLynhwKOYRBjqvf\\n+p38ynUzucGbV/vSC8meuW/AQPe3Nn9MFYQ4znEYrSNLbTWRRA3idvSEtHfffqDA\\nDHRBI1eIlxh1OTIR3L+HhcNYuus1LuoKnSlmwLGhAZLt7fjuWK3PkOiFT15e0M9M\\nUhp3WwhHcRC0o6bxT+BWRYKMVX3Vjlro4sF1fq4+jePThX1bpJcPfUmsC95tXPfX\\njfNGAxHlZ+MS1V/cLlIqyz7drXBcwCDJtbPmvavmNQKBgQD1ZR/ePHcjXUGM57U8\\nbxPatNOrcicvaP2AtTA6Y/JjfbcydkVXsenDGk0h6hykpIiMwrAaJuUTjGeM6QTI\\nOhK0k1QbGitcM71d9TSdLzWUdb3yvsyaPZlPR/6u2FBb6Bf9rWOQRYYyv1Lvu0+1\\nYLnR4sHBxiAur1NGxuHfA4ZUOwKBgQDWj+fcS/x/ifbCqexr7teWU+tUeyG3eLGA\\nMmB9eCkY7djl0/LHu/IHgqrGRVgra3IB1uI7Wr3jZYvlS7qGL3KpjeIPYj7LTQC5\\nznm0875NvJELPjQK/A4EM3mC057QRvb7y52KBNKJi7/JwHU7VHmudB78e7uGlW2K\\n5Ccl0PJFxQKBgCWv5yoJXT64JsYOG95xLLptBQkSmgQE+tHWgdal3Ob8urLsSRAD\\nyePl2Sy5OLbscfA0Qjlx+cJ70LdqXgqmKJNFASi8ZyZc59tTOkZdprvrLUXnmaKi\\njTYI14tgu06yIWUbSOwyUT7f9UvOF5rChSc/zQQGepDQ6lg3WR8X+nxbAoGBAKiu\\nfAcqSfjuuuuxcWgtXpoVoaZKI2i9Xza85DTf+ddabjHJXk3+iTm0VZQIwldoYjnl\\n+PfW0ABtPf1net2xgcChBf84Ksvj3tU06WQEWDF/NLyVC48zN8W/viDHREzT7app\\nGpJ+VhLCpmXzg3bAY+Vt70pp8DTPV05hLhHB4iZNAoGBAJW+bYh7jE61+58VpjvF\\nP36BK09jEEPWVucJdghb2mb62iA6JDy3ApU+8FzckXHDewt0sqvsW4VqukgwVZx3\\npSC7mR4B+Fm6znm0Z5mBWiG5bOOgTJ0mRZv4cYgC+JRRF/E3yYR58RyAKFAFIAFH\\nng8XYP1wQp64Fzv4+rUSwM49\\n-----END PRIVATE KEY-----\\n", + "client_email": "test@test-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } + """; + + private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); + + @BeforeEach + void enableSsrfProtection() { + SsrfProtectionValidator.setEnabled(true); + } + + @AfterEach + void disableSsrfProtection() { + SsrfProtectionValidator.setEnabled(false); + } + + @Test + void configureChatModel_openAi_withPrivateIp_shouldThrow() { + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://172.17.0.1:8080/") + .apiKey("test") + .build()) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_openAi_withLocalhostUrl_shouldThrow() { + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://localhost:22/") + .apiKey("test") + .build()) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() { + var config = AzureOpenAiChatModelConfig.builder() + .providerConfig(new AzureOpenAiProviderConfig( + "http://10.0.0.1:8080/", null, "test-key")) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_ollama_withPrivateIp_shouldThrow() { + var config = OllamaChatModelConfig.builder() + .providerConfig(new OllamaProviderConfig( + "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None())) + .modelId("llama3") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() { + // GIVEN + var providerConfig = new GoogleVertexAiGeminiProviderConfig( + "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY + ); + var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder() + .providerConfig(providerConfig) + .modelId("gemini-2.0-flash") + .frequencyPenalty(0.3) + .presencePenalty(0.7) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(chatModelConfig); + + // THEN + var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig"); + assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f); + assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java new file mode 100644 index 0000000000..ee83f9df64 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.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.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.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 java.util.Collections; +import java.util.List; +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.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultCalculatedFieldCacheTest { + + @Mock + private CalculatedFieldService calculatedFieldService; + @Mock + private DeviceService deviceService; + @Mock + private AssetService assetService; + @Mock + private CustomerService customerService; + + private DefaultCalculatedFieldCache cache; + + @BeforeEach + public void setUp() { + cache = new DefaultCalculatedFieldCache(calculatedFieldService, null, null); + } + + // --- 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_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_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(); + } + + // --- 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); + } + + 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.emptyList()); + 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(List.of(linkedEntity)); + when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(List.of(link)); + when(config.buildCalculatedFieldLink(any(), eq(linkedEntity), any())).thenReturn(link); + return config; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java index a16cf99c1d..838695e9a3 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.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.IdBased; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.relation.EntitySearchDirection; @@ -43,10 +51,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; @DaoSqlTest @TestPropertySource(properties = { @@ -103,6 +114,100 @@ public class EdqsEntityServiceTest extends EntityServiceTest { assetService.deleteAssetsByTenantId(tenantId); } + // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC + @Override + @Test + public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(1L, 0L, 0L); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test")); + + EntityDataSortOrder ascSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC); + EntityDataQuery ascQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null); + List ascTelemetry = loadAllData(ascQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "0", "0", "1")); + + EntityDataSortOrder descSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC); + EntityDataQuery descQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null); + List descTelemetry = loadAllData(descQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(descTelemetry).containsExactlyElementsOf(List.of("1", "0", "0", "", "")); + } + + // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC + @Override + @Test + public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(true, false, false); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test")); + + EntityDataSortOrder ascSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC); + EntityDataQuery ascQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null); + List ascTelemetry = loadAllData(ascQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "false", "false", "true")); + + EntityDataSortOrder descSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC); + EntityDataQuery descQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null); + List descTelemetry = loadAllData(descQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(descTelemetry).containsExactlyElementsOf(List.of("true", "false", "false", "", "")); + } + @Override protected PageData findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) { return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query), diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index 05ab1165f6..b3230b3d45 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java @@ -26,6 +26,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; @@ -47,6 +48,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; @@ -79,7 +81,6 @@ import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.data.query.StringFilterPredicate.StringOperation; -import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; @@ -100,6 +101,7 @@ import org.thingsboard.server.dao.entityview.EntityViewDao; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.sql.query.DefaultEntityQueryRepository; import org.thingsboard.server.dao.sql.relation.RelationRepository; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; @@ -124,7 +126,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE; import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE; import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; -import static org.thingsboard.server.common.data.query.EntityKeyType.SERVER_ATTRIBUTE; @Slf4j @DaoSqlTest @@ -154,6 +155,8 @@ public class EntityServiceTest extends AbstractControllerTest { @Autowired RelationRepository relationRepository; @Autowired + DefaultEntityQueryRepository entityQueryRepository; + @Autowired RelationService relationService; @Autowired TimeseriesService timeseriesService; @@ -1750,6 +1753,117 @@ public class EntityServiceTest extends AbstractControllerTest { deviceService.deleteDevicesByTenantId(tenantId); } + @Test + public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + try { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(1L, 0L, 0L); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + assertNullsOrdering("default", + List.of("0", "0", "1", "", ""), + List.of("", "", "1", "0", "0"), + devices.size()); + + assertNullsOrdering("nulls_first", + List.of("", "", "0", "0", "1"), + List.of("", "", "1", "0", "0"), + devices.size()); + + assertNullsOrdering("nulls_last", + List.of("0", "0", "1", "", ""), + List.of("1", "0", "0", "", ""), + devices.size()); + } finally { + deviceService.deleteDevicesByTenantId(tenantId); + } + } + + @Test + public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + try { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(true, false, false); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + assertNullsOrdering("default", + List.of("false", "false", "true", "", ""), + List.of("", "", "true", "false", "false"), + devices.size()); + + assertNullsOrdering("nulls_first", + List.of("", "", "false", "false", "true"), + List.of("", "", "true", "false", "false"), + devices.size()); + + assertNullsOrdering("nulls_last", + List.of("false", "false", "true", "", ""), + List.of("true", "false", "false", "", ""), + devices.size()); + } finally { + deviceService.deleteDevicesByTenantId(tenantId); + } + } + + private void assertNullsOrdering(String strategy, List expectedAsc, List expectedDesc, int deviceSize) { + String originalStrategy = entityQueryRepository.getNullsOrderStrategy(); + ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", strategy); + try { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test")); + + EntityDataSortOrder ascSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC); + EntityDataQuery ascQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null); + List ascTelemetry = loadAllData(ascQuery, deviceSize).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(ascTelemetry).as("ASC with strategy '%s'", strategy).containsExactlyElementsOf(expectedAsc); + + EntityDataSortOrder descSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC); + EntityDataQuery descQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null); + List descTelemetry = loadAllData(descQuery, deviceSize).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(descTelemetry).as("DESC with strategy '%s'", strategy).containsExactlyElementsOf(expectedDesc); + } finally { + ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", originalStrategy); + } + } + @Test public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException { // save timeseries by sys admin @@ -2321,12 +2435,18 @@ public class EntityServiceTest extends AbstractControllerTest { return timeseriesService.save(tenantId, entityId, timeseries); } - private ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) { + protected ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) { KvEntry telemetryValue = new LongDataEntry(key, value); BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); return timeseriesService.save(tenantId, entityId, timeseries); } + protected ListenableFuture saveTimeseries(EntityId entityId, String key, Boolean value) { + KvEntry telemetryValue = new BooleanDataEntry(key, value); + BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); + return timeseriesService.save(tenantId, entityId, timeseries); + } + protected void createMultiRootHierarchy(List buildings, List apartments, Map> entityNameByTypeMap, Map childParentRelationMap) throws InterruptedException { diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java new file mode 100644 index 0000000000..f9b8d428d7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java @@ -0,0 +1,159 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.profile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbAssetProfileCacheTest { + + @Mock + private AssetProfileService assetProfileService; + @Mock + private AssetService assetService; + + private DefaultTbAssetProfileCache cache; + + @BeforeEach + public void setUp() { + cache = new DefaultTbAssetProfileCache(assetProfileService, assetService); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsAssetProfilesForThatTenant() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); + + loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); + + // After deletion tenant1 profile should be reloaded from service on next get + when(assetProfileService.findAssetProfileById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant1, profileId1)).isNull(); + verify(assetProfileService, times(1)).findAssetProfileById(tenant2, profileId2); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsAssetMappingsForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + AssetId assetId = new AssetId(UUID.randomUUID()); + + loadProfileIntoCache(tenant, profileId); + loadAssetMappingIntoCache(tenant, assetId, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // After tenant deletion, asset-to-profile mapping should be gone; get() should try to reload + when(assetService.findAssetById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant, assetId)).isNull(); + verify(assetService, times(2)).findAssetById(tenant, assetId); // once on load, once after eviction + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + EntityId listenerId = new AssetId(UUID.randomUUID()); + AtomicInteger callCount = new AtomicInteger(); + + cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); + + AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) { + AssetProfile profile = new AssetProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) { + Asset asset = new Asset(); + asset.setId(assetId); + asset.setAssetProfileId(profileId); + when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset); + cache.get(tenantId, assetId); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java new file mode 100644 index 0000000000..a26413514c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.profile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbDeviceProfileCacheTest { + + @Mock + private DeviceProfileService deviceProfileService; + @Mock + private DeviceService deviceService; + + private DefaultTbDeviceProfileCache cache; + + @BeforeEach + public void setUp() { + cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); + + // After deletion tenant1 profile should be reloaded from service on next get + when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant1, profileId1)).isNull(); + // tenant2 profile should still be served from cache (no extra service call) + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + loadProfileIntoCache(tenant, profileId); + loadDeviceMappingIntoCache(tenant, deviceId, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // After tenant deletion, device-to-profile mapping should be gone; get() should try to reload + when(deviceService.findDeviceById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant, deviceId)).isNull(); + verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + EntityId listenerId = new DeviceId(UUID.randomUUID()); + AtomicInteger callCount = new AtomicInteger(); + + cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) { + DeviceProfile profile = new DeviceProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) { + Device device = new Device(); + device.setId(deviceId); + device.setDeviceProfileId(profileId); + when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); + cache.get(tenantId, deviceId); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/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/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 de1312ba37..4d5fff8157 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index e77b3a0082..f3f4f47bb9 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 4ee7ff01c7..bd0c1e6016 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index a8b5a17be8..55f2ee4896 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 672de6bacd..c6e90d1351 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 b26fff28c5..f2a1fabfbd 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java index f412c5488a..406263aaed 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import java.util.Collection; +import java.util.Set; public interface ImageService { @@ -38,6 +39,8 @@ public interface ImageService { TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key); + Set getAllImageKeysByTenantId(TenantId tenantId); + TbResourceInfo getPublicImageInfoByKey(String publicResourceKey); PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink); diff --git a/common/data/pom.xml b/common/data/pom.xml index 8ba5a7f2bc..e14e7072ea 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT common org.thingsboard.common 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/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/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/discovery-api/pom.xml b/common/discovery-api/pom.xml index 0d44e96441..0d75e57b9a 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 91b2432fd8..bb0d5cb192 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 e2f565c7a3..70f90cc45b 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -47,6 +47,7 @@ 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_LATEST = 99999; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 9ee49a5c8a..4b2cd36592 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 cf9de4cdd0..69c4e72e2a 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 0830e12154..79059a9a56 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 @@ -204,18 +204,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 ff5ebe753b..ab2388eb9d 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index 0fb46deb41..023b2bc46a 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 29f4777c3e..7ad4d2883d 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -132,7 +132,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 9c77b12392..1cf8320468 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 07726f0254..7f12f6aab5 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java @@ -98,7 +98,10 @@ public abstract class AbstractTbQueueConsumerTemplate i doSubscribe(partitions); subscribed = true; } - records = partitions.isEmpty() ? emptyList() : doPoll(durationInMillis); + if (partitions.isEmpty()) { + return sleepAndReturnEmpty(startNanos, durationInMillis); + } + records = doPoll(durationInMillis); } finally { consumerLock.unlock(); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java index bf884958eb..279a2ddc7f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java @@ -32,6 +32,7 @@ import org.thingsboard.server.queue.util.PropertyUtils; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType.SOFT; @@ -59,41 +60,48 @@ public class DefaultNotificationDeduplicationService implements NotificationDedu } private boolean alreadyProcessed(NotificationRuleTrigger trigger, String deduplicationKey, boolean onlyLocalCache) { - Long lastProcessedTs = localCache.get(deduplicationKey); - if (lastProcessedTs == null && !onlyLocalCache) { - Cache externalCache = getExternalCache(); - if (externalCache != null) { - lastProcessedTs = externalCache.get(deduplicationKey, Long.class); - } else { - log.warn("Sent notifications cache is not set up"); + long deduplicationDuration = getDeduplicationDuration(trigger); + final long now = System.currentTimeMillis(); + boolean[] result = {false}; + + localCache.compute(deduplicationKey, (key, lastProcessedTs) -> { + if (lastProcessedTs == null && !onlyLocalCache) { + Cache externalCache = getExternalCache(); + if (externalCache != null) { + lastProcessedTs = externalCache.get(key, Long.class); + if (lastProcessedTs != null && lastProcessedTs > now + TimeUnit.HOURS.toMillis(1)) { + log.warn("Discarding dedup entry from external cache for key '{}': timestamp is {} ms in the future", + key, lastProcessedTs - now); + lastProcessedTs = null; + } + } else { + log.warn("Sent notifications cache is not set up"); + } } - } - boolean alreadyProcessed = false; - long deduplicationDuration = getDeduplicationDuration(trigger); - if (lastProcessedTs != null) { - long passed = System.currentTimeMillis() - lastProcessedTs; - log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms", - trigger.getType(), deduplicationKey, deduplicationDuration, passed); - if (deduplicationDuration == 0 || passed <= deduplicationDuration) { - alreadyProcessed = true; + if (lastProcessedTs != null) { + long passed = now - lastProcessedTs; + log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms", + trigger.getType(), key, deduplicationDuration, passed); + if (deduplicationDuration == 0 || passed <= deduplicationDuration) { + result[0] = true; + return lastProcessedTs; + } } - } - if (!alreadyProcessed) { - lastProcessedTs = System.currentTimeMillis(); - } - localCache.put(deduplicationKey, lastProcessedTs); + return now; + }); + if (!onlyLocalCache) { - if (!alreadyProcessed || deduplicationDuration == 0) { + if (!result[0] || deduplicationDuration == 0) { // if lastProcessedTs is changed or if deduplicating infinitely (so that cache value not removed by ttl) Cache externalCache = getExternalCache(); if (externalCache != null) { - externalCache.put(deduplicationKey, lastProcessedTs); + externalCache.put(deduplicationKey, now); } } } - return alreadyProcessed; + return result[0]; } public static String getDeduplicationKey(NotificationRuleTrigger trigger, NotificationRule rule) { diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java new file mode 100644 index 0000000000..a016408ad4 --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java @@ -0,0 +1,146 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.common; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.TbQueueMsg; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.spy; +import static org.mockito.BDDMockito.times; +import static org.mockito.BDDMockito.verify; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class AbstractTbQueueConsumerTemplateTest { + + private static final long POLL_DURATION_MS = 100L; + private static final long SLEEP_TOLERANCE_MS = 20L; + + @Test + public void givenEmptyPartitionsAndLongPollingSupported_whenPoll_thenSleepsAndDoesNotCallDoPoll() { + // Regression: with empty partitions AND isLongPollingSupported()==true (e.g. Kafka), + // poll() previously returned instantly with no sleep, causing the consumer loop to busy-spin. + TestConsumer consumer = spy(new TestConsumer("test-topic", true)); + consumer.subscribe(Collections.emptySet()); + + long startNs = System.nanoTime(); + List result = consumer.poll(POLL_DURATION_MS); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + + assertThat(result, is(empty())); + verify(consumer, never()).doPoll(anyLong()); + assertThat("poll() must sleep ~durationInMillis when partitions are empty (no busy-wait)", + elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS)); + } + + @Test + public void givenEmptyPartitionsAndNoLongPolling_whenPoll_thenSleepsAndDoesNotCallDoPoll() { + TestConsumer consumer = spy(new TestConsumer("test-topic", false)); + consumer.subscribe(Collections.emptySet()); + + long startNs = System.nanoTime(); + List result = consumer.poll(POLL_DURATION_MS); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + + assertThat(result, is(empty())); + verify(consumer, never()).doPoll(anyLong()); + assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS)); + } + + @Test + public void givenNonEmptyPartitions_whenPoll_thenCallsDoPoll() { + TestConsumer consumer = spy(new TestConsumer("test-topic", true)); + consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true))); + + List result = consumer.poll(POLL_DURATION_MS); + + assertThat(result, is(empty())); + verify(consumer, times(1)).doPoll(POLL_DURATION_MS); + } + + @Test + public void givenPartitionsBecomeEmptyAfterRebalance_whenPollAgain_thenStopsCallingDoPoll() { + // Reproduces the observed trigger: a rebalance leaves the consumer with an empty + // partition assignment. Subsequent poll() calls must not busy-spin or call doPoll(). + TestConsumer consumer = spy(new TestConsumer("test-topic", true)); + consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true))); + consumer.poll(POLL_DURATION_MS); + verify(consumer, times(1)).doPoll(POLL_DURATION_MS); + + consumer.subscribe(Collections.emptySet()); + + long startNs = System.nanoTime(); + List result = consumer.poll(POLL_DURATION_MS); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + + assertThat(result, is(empty())); + verify(consumer, times(1)).doPoll(anyLong()); + assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS)); + } + + static class TestConsumer extends AbstractTbQueueConsumerTemplate { + + private final boolean longPollingSupported; + + TestConsumer(String topic, boolean longPollingSupported) { + super(topic); + this.longPollingSupported = longPollingSupported; + } + + @Override + protected List doPoll(long durationInMillis) { + return Collections.emptyList(); + } + + @Override + protected TbQueueMsg decode(Object record) { + return null; + } + + @Override + protected void doSubscribe(Set partitions) { + } + + @Override + protected void doCommit() { + } + + @Override + protected void doUnsubscribe() { + } + + @Override + protected boolean isLongPollingSupported() { + return longPollingSupported; + } + } + +} diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java new file mode 100644 index 0000000000..826c1e1ef6 --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.notification; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.notification.rule.NotificationRule; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultNotificationDeduplicationServiceTest { + + private static final int TIMEOUT = 30; + + private DefaultNotificationDeduplicationService deduplicationService; + private CacheManager cacheManager; + + @BeforeEach + void setUp() { + deduplicationService = new DefaultNotificationDeduplicationService(); + deduplicationService.setDeduplicationDurations(""); + cacheManager = new ConcurrentMapCacheManager(CacheConstants.SENT_NOTIFICATIONS_CACHE); + ReflectionTestUtils.setField(deduplicationService, "cacheManager", cacheManager); + } + + @Test + void testFirstTriggerIsNotDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testSecondTriggerIsDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); + } + + @Test + void testTriggerPassesAfterDeduplicationWindowExpires() { + NotificationRuleTrigger trigger = mockTrigger(50); // 50ms dedup window + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + + try { + Thread.sleep(200); // wait well past the 50ms window + } catch (InterruptedException ignored) {} + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testFutureTimestampFromExternalCacheIsDiscarded() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); + + // Put a timestamp 2 hours in the future into external cache + Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); + externalCache.put(dedupKey, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2)); + + // Should NOT be deduplicated — future timestamp must be discarded + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testValidTimestampFromExternalCacheIsDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); + + // Put a recent timestamp into external cache + Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); + externalCache.put(dedupKey, System.currentTimeMillis()); + + // Should be deduplicated — valid external cache entry + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); + } + + @Test + void testConcurrentTriggersProduceExactlyOneNonDeduplicated() throws Exception { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + int threadCount = 10; + CyclicBarrier barrier = new CyclicBarrier(threadCount); + List results = new CopyOnWriteArrayList<>(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + barrier.await(TIMEOUT, TimeUnit.SECONDS); + } catch (Exception ignored) {} + results.add(deduplicationService.alreadyProcessed(trigger, rule)); + }); + } + executor.shutdown(); + assertThat(executor.awaitTermination(TIMEOUT, TimeUnit.SECONDS)).isTrue(); + + assertThat(results).hasSize(threadCount); + assertThat(results.stream().filter(r -> !r).count()) + .as("exactly one trigger should pass through deduplication") + .isEqualTo(1); + } finally { + executor.shutdownNow(); + } + } + + private NotificationRuleTrigger mockTrigger(long deduplicationDurationMs) { + NotificationRuleTrigger trigger = mock(NotificationRuleTrigger.class); + when(trigger.getType()).thenReturn(NotificationRuleTriggerType.RESOURCES_SHORTAGE); + when(trigger.getDeduplicationKey()).thenReturn("test:dedup:key"); + when(trigger.getDefaultDeduplicationDuration()).thenReturn(deduplicationDurationMs); + when(trigger.getDeduplicationStrategy()).thenReturn(NotificationRuleTrigger.DeduplicationStrategy.ONLY_MATCHING); + return trigger; + } + + private NotificationRule mockRule() { + NotificationRule rule = mock(NotificationRule.class); + when(rule.getDeduplicationKey()).thenReturn("rule:key"); + return rule; + } + +} diff --git a/common/script/pom.xml b/common/script/pom.xml index 49d0aa11a4..3527a1c695 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 098b1de983..6746100ef2 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.2.2.1 + 4.2.2.2-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index 9736c7949e..6a55f3cc71 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.2.2.1 + 4.2.2.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.2.2.1 + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index 02c33b5250..3e63520ce2 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1 + 4.2.2.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 369430600f..3a06449bd9 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1 + 4.2.2.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..73d77e2ddb 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -76,10 +76,6 @@ import java.util.List; import java.util.UUID; import java.util.function.Consumer; - -/** - * @author Andrew Shvayka - */ @RestController @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @RequestMapping("/api/v1") diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java index 830e081dfd..db72ba747c 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java @@ -26,9 +26,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportContext; -/** - * Created by ashvayka on 04.10.18. - */ @Slf4j @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @Component @@ -52,4 +49,5 @@ public class HttpTransportContext extends TransportContext { } }; } + } diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index c02eedda0b..9267d74dd2 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1 + 4.2.2.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 d2b24fe9a8..c092dcea77 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 60934c3dc0..b63ad8a1ac 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1 + 4.2.2.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 3d05e999e0..166a67368c 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 18173930aa..d77303b40c 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 3ac9fa1fea..f746b58b7d 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.2.2.1 + 4.2.2.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 d61b532745..10ce5ede63 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.2.2.1 + 4.2.2.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 5c7d552855..939f2278ce 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 openConnections, AtomicInteger connectionsCounter); + } 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 260957c990..240de91424 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 @@ -1270,4 +1273,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 3455d8f8e7..4748d75d92 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 1b87582735..66c87eb5d1 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 9c7d2849e9..bc172eeb45 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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/device/DeviceConnectivityServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java index 89cb538c28..70f5f48272 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java @@ -85,7 +85,7 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService private String mqttsPemCertFile; @Value("${device.connectivity.coaps.pem_cert_file:}") private String coapsPemCertFile; - @Value("${device.connectivity.gateway.image_version:3.7-stable}") + @Value("${device.connectivity.gateway.image_version:3.8-stable}") private String gatewayImageVersion; @Override 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 9b138fe279..3d6c420e4c 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.dao.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 006661536c..86a6e5224b 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,7 +18,9 @@ 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.dao.alarm.AlarmCommentDao; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; @@ -26,6 +28,8 @@ import org.thingsboard.server.dao.service.DataValidator; @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,19 @@ public class AlarmCommentDataValidator extends DataValidator { throw new DataValidationException("Alarm id should be specified!"); } } + + @Override + protected AlarmComment validateUpdate(TenantId tenantId, AlarmComment alarmComment) { + AlarmComment oldAlarmComment = null; + if (alarmComment.getId() != null) { + oldAlarmComment = alarmCommentDao.findAlarmCommentById(tenantId, alarmComment.getId().getId()); + if (oldAlarmComment == null) { + throw new DataValidationException("Can't update non existing alarm comment!"); + } + if (oldAlarmComment.getType() == AlarmCommentType.SYSTEM) { + throw new DataValidationException("System alarm comment can't be updated!"); + } + } + return oldAlarmComment; + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index 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 c259d48372..496b4eb3bd 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 @@ -494,8 +494,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/ts/TsKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java index 6cfe1c7b36..10c5ae4185 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java @@ -61,8 +61,10 @@ public interface TsKvRepository extends JpaRepository processMinOrMaxResult(AggregationResult aggResult) { if (aggResult.dataType == DataType.DOUBLE || aggResult.dataType == DataType.LONG) { if (aggResult.hasDouble) { - double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(Double.MIN_VALUE); + double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(-Double.MAX_VALUE); double currentL = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.lValue).orElse(Long.MAX_VALUE) : Optional.ofNullable(aggResult.lValue).orElse(Long.MIN_VALUE); return Optional.of(new BasicTsKvEntry(ts, new DoubleDataEntry(key, aggregation == Aggregation.MIN ? Math.min(currentD, currentL) : Math.max(currentD, currentL)))); } else { diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java index 5b50d062a2..d1d31c4745 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java @@ -117,24 +117,26 @@ public class DeviceConnectivityUtil { dockerComposeBuilder.append("\n"); dockerComposeBuilder.append(" # Environment variables\n"); dockerComposeBuilder.append(" environment:\n"); - dockerComposeBuilder.append(" - host=").append(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host).append("\n"); - dockerComposeBuilder.append(" - port=1883\n"); + dockerComposeBuilder.append(" - TB_GW_HOST=").append(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host).append("\n"); + dockerComposeBuilder.append(" - TB_GW_PORT=1883\n"); switch (deviceCredentials.getCredentialsType()) { case ACCESS_TOKEN: - dockerComposeBuilder.append(" - accessToken=").append(deviceCredentials.getCredentialsId()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=accessToken\n"); + dockerComposeBuilder.append(" - TB_GW_ACCESS_TOKEN=").append(deviceCredentials.getCredentialsId()).append("\n"); break; case MQTT_BASIC: + dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=usernamePassword\n"); BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials.class); if (credentials != null) { if (StringUtils.isNotEmpty(credentials.getClientId())) { - dockerComposeBuilder.append(" - clientId=").append(credentials.getClientId()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_CLIENT_ID=").append(credentials.getClientId()).append("\n"); } if (StringUtils.isNotEmpty(credentials.getUserName())) { - dockerComposeBuilder.append(" - username=").append(credentials.getUserName()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_USERNAME=").append(credentials.getUserName()).append("\n"); } if (StringUtils.isNotEmpty(credentials.getPassword())) { - dockerComposeBuilder.append(" - password=").append(credentials.getPassword()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_PASSWORD=").append(credentials.getPassword()).append("\n"); } } break; diff --git a/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java b/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java new file mode 100644 index 0000000000..8afcaa1c22 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.TimescaleDBContainerProvider; + +/** + * Extends the upstream {@link TimescaleDBContainerProvider} to disable the + * timescaledb-tune entrypoint script via NO_TS_TUNE=true. + * + * Works around a shell bug in /docker-entrypoint-initdb.d/001_timescaledb_tune.sh + * that crashes the container entrypoint on cgroup v2 hosts (including CI agents) + * when the kernel reports the 64-bit max for memory.max. + * + * Activated by the jdbc:tc:tbtimescaledb:<tag>:///... URL prefix + * registered via META-INF/services. + */ +public class TbTimescaleDBContainerProvider extends TimescaleDBContainerProvider { + + private static final String NAME = "tbtimescaledb"; + + @Override + public boolean supports(String databaseType) { + return NAME.equals(databaseType); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + JdbcDatabaseContainer container = super.newInstance(tag); + container.withEnv("NO_TS_TUNE", "true"); + return container; + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java index e1093e4f46..2329111e22 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; @@ -57,6 +58,7 @@ import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.user.UserService; @@ -64,6 +66,8 @@ 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 { @@ -987,4 +991,25 @@ public class AlarmServiceTest extends AbstractServiceTest { Assert.assertEquals(1, alarmsCount); } + @Test + public void testShouldFailToCreateAlarmWithBadType() { + AssetId originatorId = new AssetId(Uuids.timeBased()); + + long ts = System.currentTimeMillis(); + AlarmCreateOrUpdateActiveRequest request = AlarmCreateOrUpdateActiveRequest.builder() + .tenantId(tenantId) + .originator(originatorId) + .type("") + .severity(AlarmSeverity.CRITICAL) + .startTs(ts).build(); + + Assertions.assertThrows(DataValidationException.class, () -> { + alarmService.createAlarm(request); + }); + + request.setType(TEST_ALARM); + AlarmApiCallResult result = alarmService.createAlarm(request); + assertThat(result.getAlarm().getId()).isNotNull(); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 221684c10c..e7d2d6a8fc 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -710,6 +710,31 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue()); } + @Test + public void testFindDeviceMaxAggregationOverNegativeMixedLongAndDoubleTsData() throws Exception { + save(deviceId, 5000, -100L); + save(deviceId, 15000, -50.0); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(1, list.size()); + assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue()); + } + + @Test + public void testFindDeviceMaxAggregationOverAllNegativeDoubleTsData() throws Exception { + save(deviceId, 5000, -50.0); + save(deviceId, 15000, -100.0); + save(deviceId, 25000, -75.0); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(1, list.size()); + assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue()); + } + @Test public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception { save(deviceId, 2000000L, 95); diff --git a/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 0000000000..ab36744aa9 --- /dev/null +++ b/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.thingsboard.server.dao.TbTimescaleDBContainerProvider diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties index b688c3c40f..921aebd5fe 100644 --- a/dao/src/test/resources/nosql-test.properties +++ b/dao/src/test/resources/nosql-test.properties @@ -13,6 +13,6 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties index e3f4861aa9..0639c461a3 100644 --- a/dao/src/test/resources/sql-test.properties +++ b/dao/src/test/resources/sql-test.properties @@ -14,7 +14,7 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/timescale-test.properties b/dao/src/test/resources/timescale-test.properties index 2c5552cb75..e0c0bef25e 100644 --- a/dao/src/test/resources/timescale-test.properties +++ b/dao/src/test/resources/timescale-test.properties @@ -13,6 +13,6 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:timescaledb:latest-pg12:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb +spring.datasource.url=jdbc:tc:tbtimescaledb:latest-pg18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize = 50 diff --git a/edqs/pom.xml b/edqs/pom.xml index bdafd65dd1..9bf82c7a65 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 66fb331959..2601ac47fe 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index b9cfc55db7..c06e98b45e 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa 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 7b2cfc68d9..1e14dbd70b 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 @@ -328,6 +328,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/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 333e6fd2ad..f85e9b7691 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 a09596e6ac..9cd29e35d5 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.2.2.1", + "version": "4.2.2.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 eed0745d2c..2f7434b472 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 1022bbec6e..ab484e7df8 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index ae044180cc..84c98308cd 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 6c7c82476c..3dd84135cb 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 13443ff582..5c10adf67e 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index eaf479ddb1..f081e6fd46 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.1 + 4.2.2.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index f872fd8048..78e445fda8 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.1 + 4.2.2.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 82d0433f48..d65d07f9a9 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.1 + 4.2.2.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 230f05750f..8dfe14b3a6 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.1 + 4.2.2.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index 6c663dacfb..aa10ff1184 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index 47d5515d92..fb344e3242 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.2.2.1 + 4.2.2.2-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index 5711334014..0bc90c6ac4 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.2.2.1 + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 2466c24aef..a0c023b5f8 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.1 + 4.2.2.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..9ed3299606 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 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 1a956617cd..aaac7f8b67 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.2.2.1", + "version": "4.2.2.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 309e7ed5cb..196311f880 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 ec6b6be004..4b9b9fa21e 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT thingsboard netty-mqtt - 4.2.2.1 + 4.2.2.2-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index cab5162a84..4a8958ffa3 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT pom Thingsboard @@ -62,17 +62,22 @@ ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 3.5.12 - 2.21.1 - 4.1.132.Final + 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 +94,8 @@ 3.9.5 3.25.5 1.76.0 - 1.2.9 - 1.18.44 + 1.2.10 + 1.18.46 1.2.5 1.2.5 1.7.1 @@ -98,11 +103,12 @@ 3.5.4 3.1.4 3.1.4 + 3.5.0 2.8.8TB 2.2.30 0.8 1.19.0 - 1.78.1 + 1.84 2.0.1 org/thingsboard/server/gen/**/*, org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/* @@ -112,8 +118,7 @@ - 3.9.1 - 1.10.1 + 3.9.2 8.10.1 3.5.3 1.12.701 @@ -138,6 +143,7 @@ 1.7.5 3.8.0 1.8.0-TB + 2.5.9 2.38.0 1.24 1.11.0 @@ -619,7 +625,6 @@ org.apache.maven.plugins maven-assembly-plugin - ${pkg.skip.zip} ${pkg.name} ${main.dir}/packaging/${pkg.type}/assembly/windows.xml @@ -632,6 +637,9 @@ single + + ${pkg.skip.zip} + @@ -916,6 +924,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 +982,7 @@ org.apache.maven.plugins maven-clean-plugin + ${maven-clean-plugin.version} false @@ -993,15 +1008,6 @@ - - - com.fasterxml.jackson - jackson-bom - ${jackson-bom.version} - pom - import - - io.netty @@ -1011,6 +1017,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 @@ -1257,21 +1280,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 +1371,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 +1582,6 @@ org.apache.cassandra cassandra-all ${cassandra-all.version} - - - org.lz4 - lz4-java - - org.testng diff --git a/rest-client/pom.xml b/rest-client/pom.xml index e2192c8657..d108c49cda 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 57e4345cdd..de69670863 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 8b30ae5e6a..3810c46a99 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.2.2.1 + 4.2.2.2-SNAPSHOT rule-engine org.thingsboard.rule-engine 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 dc27f79ee2..464c2df532 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 @@ -438,4 +438,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 550f25eed8..1ac0938163 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.2.2.1 + 4.2.2.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 8a29ce76bc..6376db6d9f 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 d4684c775a..723f9a1dd2 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 9ab06ca996..1554b3fc24 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 diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 4005c9d0c2..d4e19705c9 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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..c878887a01 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 diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index 0eb9a2bffc..fac60f7edc 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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 d22bd34505..51b1ad0a2b 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 diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 44dc4daafa..7b61c03200 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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..dfe35db29c 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 diff --git a/transport/pom.xml b/transport/pom.xml index 84dde7ca23..3645cf7d73 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.2-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index 9ece0c4f72..683c0e3e2d 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.1 + 4.2.2.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..d021030a91 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 diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 42f4e21c0f..076e2d316a 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.2.2.1", + "version": "4.2.2.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 7412dce72f..4bc4bfd6cd 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1 + 4.2.2.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/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 32c315fcf8..7ccad1c91e 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'; @@ -352,6 +353,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/alarm-assignee-panel.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html index c0f983e6d0..fbbce2a469 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html @@ -42,7 +42,7 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss index c8de20ad36..1aeded1767 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss @@ -40,7 +40,11 @@ margin-left: 12px; margin-right: 20px; &.inline-icon { - margin-left: 0; + margin-left: 8px; margin-right: 8px; + padding: 6px 0 !important; + } + &.mat-icon { + font-size: 28px !important; } } diff --git a/ui-ngx/src/app/modules/home/components/attribute/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 @@ + +
+ + + +
{{ '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 9004928078..28c5f3d13b 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'; @NgModule({ declarations: [ @@ -372,7 +375,8 @@ import { DataKeysComponent, DataKeyConfigDialogComponent, DataKeyConfigComponent, - WidgetSettingsComponent + WidgetSettingsComponent, + HtmlContainerSettingsComponent ], imports: [ CommonModule, @@ -453,7 +457,8 @@ import { DataKeysComponent, DataKeyConfigDialogComponent, DataKeyConfigComponent, - WidgetSettingsComponent + WidgetSettingsComponent, + HtmlContainerSettingsComponent ], providers: [ ColorSettingsComponentService, 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 af8694d24c..dbb7258823 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'; @NgModule({ declarations: [ @@ -508,7 +511,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings LabelValueCardWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, - MapWidgetSettingsComponent + MapWidgetSettingsComponent, + HtmlContainerWidgetSettingsComponent ], imports: [ CommonModule, @@ -647,7 +651,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings LabelValueCardWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, - MapWidgetSettingsComponent + MapWidgetSettingsComponent, + HtmlContainerWidgetSettingsComponent ] }) export class WidgetSettingsModule { 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 bcd74e37f1..6b68f16a3a 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'; @NgModule({ declarations: [ @@ -151,7 +152,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane ScadaSymbolWidgetComponent, SelectMapEntityPanelComponent, MapTimelinePanelComponent, - MapWidgetComponent + MapWidgetComponent, + HtmlContainerWidgetComponent ], imports: [ CommonModule, @@ -214,7 +216,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane UnreadNotificationWidgetComponent, NotificationTypeFilterPanelComponent, ScadaSymbolWidgetComponent, - MapWidgetComponent + MapWidgetComponent, + HtmlContainerWidgetComponent ], providers: [ {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule}, 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 a792f50147..f35a1f9ae1 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 @@ -308,7 +308,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 c42a4c6b48..cb96a493a1 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 @@ -332,6 +332,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/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/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html index e6a5067783..ea9248f17a 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 e8df4b017c..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 @@ -16,7 +16,7 @@ --> {{ label }} @@ -24,6 +24,9 @@ {{ displayEntityTypeFn(type) }} + @if (!inlineField && subscriptSizing === 'dynamic') { + + } {{ 'entity.type-required' | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts index 4e6d6d4347..ea24c373d6 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts @@ -21,7 +21,7 @@ import { AliasEntityType, EntityType, entityTypeTranslations } from '@app/shared import { EntityService } from '@core/http/entity.service'; import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatFormFieldAppearance } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ selector: 'tb-entity-type-select', @@ -69,6 +69,9 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + @Input() @coerceBoolean() inlineField: boolean; diff --git a/ui-ngx/src/app/shared/import-export/import-export.models.ts b/ui-ngx/src/app/shared/import-export/import-export.models.ts index 2476922bf9..ab9def1697 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.models.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.models.ts @@ -210,7 +210,7 @@ function splitCSV(str: string, sep: string): string[] { foo = foo.shift().split(sep).concat(foo); } } else { - foo[x].replace(/""/g, '"'); + foo[x] = foo[x].replace(/""/g, '"'); } } return foo; diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index c861b33225..504e4d2659 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -110,6 +110,10 @@ export const AiModelMap = new Map= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": - version "0.2003.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.22.tgz#cb660579890be1d0622339bc74a85b6f1697ae5f" - integrity sha512-gxVOslVweD+Co6gpRVlByHus/3HVAnsl99MobS9PBh8vh2g6bJ011PBgl0TKsP/pqBGawZOkJXYrRPeMKnobYA== +"@angular-devkit/architect@0.2003.24", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": + version "0.2003.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.24.tgz#1c59822ffeada4248f669424c7c0534f39fe8bc5" + integrity sha512-E7mCdkL6SWnW60G1nGLuugmsopza/eVIrDWB1y0vLkWN8gepOvnHz2Uf637kdzed/F1WoqR+dhv1SfsaJapzKA== dependencies: - "@angular-devkit/core" "20.3.22" + "@angular-devkit/core" "20.3.24" rxjs "7.8.2" -"@angular-devkit/build-angular@20.3.22": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.22.tgz#a96ba0d41a708ed3402f2568db343b1e85fe0e4e" - integrity sha512-PnKIRue/j30sWLWC9q2T3WwE5+GJekDX/zCXTYgjW6u0y/kt+7G/sR0uxXGkX2NwSVGnIz0ucVOBsbSl9PKd6A== +"@angular-devkit/build-angular@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.24.tgz#871b7c134d9c9f77b16cd68bca340801638e56a7" + integrity sha512-zBjETGqKtiojF8VHvjELW+UAMBRJ7ziqNc1xwUtqRuWZQVz8HH6x8m8Ncw4mSq9ecCFC4VB7OWTJ1VkAdYSBnQ== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.22" - "@angular-devkit/build-webpack" "0.2003.22" - "@angular-devkit/core" "20.3.22" - "@angular/build" "20.3.22" + "@angular-devkit/architect" "0.2003.24" + "@angular-devkit/build-webpack" "0.2003.24" + "@angular-devkit/core" "20.3.24" + "@angular/build" "20.3.24" "@babel/core" "7.28.3" "@babel/generator" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" @@ -188,14 +188,14 @@ "@babel/preset-env" "7.28.3" "@babel/runtime" "7.28.3" "@discoveryjs/json-ext" "0.6.3" - "@ngtools/webpack" "20.3.22" + "@ngtools/webpack" "20.3.24" ansi-colors "4.1.3" autoprefixer "10.4.21" babel-loader "10.0.0" browserslist "^4.21.5" copy-webpack-plugin "14.0.0" css-loader "7.1.2" - esbuild-wasm "0.25.9" + esbuild-wasm "0.28.0" fast-glob "3.3.3" http-proxy-middleware "3.0.5" istanbul-lib-instrument "6.0.3" @@ -228,20 +228,20 @@ webpack-merge "6.0.1" webpack-subresource-integrity "5.1.0" optionalDependencies: - esbuild "0.25.9" + esbuild "0.28.0" -"@angular-devkit/build-webpack@0.2003.22": - version "0.2003.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.22.tgz#9769c91cfd0203b1136cdd6b6ed4d205e17da70b" - integrity sha512-ad4iW5CDGDYFXR9ZpdY8O2n4mx+tJtjx4mfC9Z+0ikwA4ir2IW2BOUhWvJ6IDq3f7kulFxP8S4zWN9r7jsZw3A== +"@angular-devkit/build-webpack@0.2003.24": + version "0.2003.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.24.tgz#9d96b1d2c05faf3bcb696059c736d9498230ba36" + integrity sha512-gfadsLK3SxbGpZQMSJGr8RK65R2mZi/VmHXdztXQHATvMpy+00CX1Nu3n+lPTxOVbC73aIczUkUbIuRC1HBCqg== dependencies: - "@angular-devkit/architect" "0.2003.22" + "@angular-devkit/architect" "0.2003.24" rxjs "7.8.2" -"@angular-devkit/core@20.3.22", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.22.tgz#6040c5673c4f4bb2eaaaac0bb9801ea6fa0886ea" - integrity sha512-1vZnZTAjGcCM+86v2al+2eiROiSw0uAWeVllfHSQe0KsKOP1FE8UUUiWChhxVn7vIxypphlfGunkeeIn1C/ZFw== +"@angular-devkit/core@20.3.24", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.24.tgz#e4108ad599d507d934b9ca990d98a4bb23bd0471" + integrity sha512-kmOjXJcbFxUI91nds9n6XZ6Y/DyQ7/TqRXbHHqvkz9RtlIpdbgWHlIZIq6mgsPOgPBzkxFjtncVARYZUI3yxaw== dependencies: ajv "8.18.0" ajv-formats "3.0.1" @@ -250,12 +250,12 @@ rxjs "7.8.2" source-map "0.7.6" -"@angular-devkit/schematics@20.3.22", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.22.tgz#e1aab6eaa431f323db2425af05276dbe45c63fc2" - integrity sha512-gN2XSXRn3eErGEJlH0iSfQZZ7NdxVZNdjSxuVEGBEFhe3cVeC21LzM3GTWW6xwtBb4pxHglFyc7BUFiYtZiYtg== +"@angular-devkit/schematics@20.3.24", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.24.tgz#4631ec0755363173db0e89eadc0d756985e0bae1" + integrity sha512-B5fBPi0xnEDI0wLLkCjsrYjazRPyf+rnHLAHi34thMdeY9dqljJGWYdNuyUUBak6HNPBLdEo1EUSNcOF9OWt4A== dependencies: - "@angular-devkit/core" "20.3.22" + "@angular-devkit/core" "20.3.24" jsonc-parser "3.3.1" magic-string "0.30.17" ora "8.2.0" @@ -321,20 +321,20 @@ dependencies: "@angular-eslint/bundled-angular-compiler" "20.7.0" -"@angular/animations@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.18.tgz#e675e045839d559b4917053eb0f74313bf3235ef" - integrity sha512-XFxgSyjfs0SRD2vQVFJljmM4z9nTvUoI8TRqSre/+l8D2FgzD5pG67Aj2BgDgpSFAUkIcI37G48ijK7a3ZZ3WA== +"@angular/animations@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.19.tgz#636cf19528c548427f3f2ce6c4dd3b863b1475c4" + integrity sha512-/FjU9i7J58/yBURhgVSIiLDcuyOfJxAa0b7ZrOsx6P+FES+M2T2BKZl5V2NuiP2fDFtjsV7U+M/Z9UNUmeHCEw== dependencies: tslib "^2.3.0" -"@angular/build@20.3.22", "@angular/build@^20.0.0": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.22.tgz#c29b7980a96f6353b2d1c5cc2d7bcaf82c46d181" - integrity sha512-sxjVZU6AZHXyKRHJUMawXOj4qMf3vm8XK6wUejr01UKj6BqW2YWaQO26RpRJssXD2ITTqn6+UBwL7pEwe2a4Jg== +"@angular/build@20.3.24", "@angular/build@^20.0.0": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.24.tgz#79e423a077a4177074b52e3a20c76f082fc08824" + integrity sha512-AMGXOr268y+kVutl4LpOXY2xv9P+RXLCyXUkzYwi8XwGyxAJZfyu/L5qtcO2llExp5CuvP0OxkWxk4JOGRi9TA== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.22" + "@angular-devkit/architect" "0.2003.24" "@babel/core" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" "@babel/helper-split-export-declaration" "7.24.7" @@ -342,7 +342,7 @@ "@vitejs/plugin-basic-ssl" "2.1.0" beasties "0.3.5" browserslist "^4.23.0" - esbuild "0.25.9" + esbuild "0.28.0" https-proxy-agent "7.0.6" istanbul-lib-instrument "6.0.3" jsonc-parser "3.3.1" @@ -357,7 +357,7 @@ semver "7.7.2" source-map-support "0.5.21" tinyglobby "0.2.14" - vite "7.1.11" + vite "7.3.2" watchpack "2.4.4" optionalDependencies: lmdb "3.4.2" @@ -370,18 +370,18 @@ parse5 "^8.0.0" tslib "^2.3.0" -"@angular/cli@20.3.22": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.22.tgz#ce00d20d55f458de39c80b080a749d4d03e4f9b6" - integrity sha512-0uyQPF0gGuzioWJKNyOzWSQrrC5GiidR+8gz1lODoJTnJZZdsP5n3nvccbcRmhy55B1WByHvQBE+6eDBbh06/g== +"@angular/cli@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.24.tgz#e6ab456b83eede47ccc3503dd6068bdf04be4e05" + integrity sha512-TT7LldRJPCi1VGBJMzcrYU+P3w2G6zgubwhFUdJthUiS77A+At6WJqaRY2BdIS3l7HZOXTEaU2Vj2Gkf2ol2Yw== dependencies: - "@angular-devkit/architect" "0.2003.22" - "@angular-devkit/core" "20.3.22" - "@angular-devkit/schematics" "20.3.22" + "@angular-devkit/architect" "0.2003.24" + "@angular-devkit/core" "20.3.24" + "@angular-devkit/schematics" "20.3.24" "@inquirer/prompts" "7.8.2" "@listr2/prompt-adapter-inquirer" "3.0.1" "@modelcontextprotocol/sdk" "1.26.0" - "@schematics/angular" "20.3.22" + "@schematics/angular" "20.3.24" "@yarnpkg/lockfile" "1.1.0" algoliasearch "5.35.0" ini "5.0.0" @@ -394,17 +394,17 @@ yargs "18.0.0" zod "4.1.13" -"@angular/common@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.18.tgz#311dc0658be69f1368db2eeea92ea1b940329975" - integrity sha512-M62oQbSTRmnGavIVCwimoadg/PDWadgNhactMm9fgH0eM9rx+iWBAYJk4VufO0bwOhysFpRZpJgXlFjOifz/Jw== +"@angular/common@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.19.tgz#fb19c335a5a5ea84cd6c90a4abda99c2de5c8b17" + integrity sha512-hcB1eUEN8LGcKGc4DlRJ+abS6AYfbEHDZKg8LnXNugkbwI6Ebyh2AUYTDhzZL2S4aH+C8biHKgSYHFCqieCRhA== dependencies: tslib "^2.3.0" -"@angular/compiler-cli@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.18.tgz#0f4726d1624c9def0f60c65f60e61a226676459c" - integrity sha512-zsoEgLgnblmRbi47YwMghKirJ8IBKJ3+I8TxLBRIBrhx+KHFp+6oeDeLyu9H+djdyk88zexVd09wzR/YK73F0g== +"@angular/compiler-cli@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.19.tgz#baeb020e693a759b0b8190931611309678b1e011" + integrity sha512-ET/JjO8s62kAHfgIsGXlvW5VUwLqHm03q1y/2yD7aQW/WdDvssMsvZv7Knl440989vdOFemIGTMwVPakmWqRmA== dependencies: "@babel/core" "7.28.3" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -415,31 +415,31 @@ tslib "^2.3.0" yargs "^18.0.0" -"@angular/compiler@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.18.tgz#5370dc1a24d55623828a2b0875c776c8da13fdc6" - integrity sha512-AaP/LCiDNcYmF135EEozjyR04NRBT38ZfBHQwjhgwiBBTejmvcpHwJaHSkraLpZqZzE4BQqqmgiQ1EJqxEwLVA== +"@angular/compiler@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.19.tgz#cce87072c55c1bab3fda92d83048d0136cea83ad" + integrity sha512-ETkgDKm0l2PuaBubgPJe0ccy8kE75DFu6/zKcz7TUuk3KrKF2OZAopbbjftsUSZGeCNvCdqHzjmcL6hQ6oAOwA== dependencies: tslib "^2.3.0" -"@angular/core@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.18.tgz#af91805841184cd87e862134a806bba019d170ea" - integrity sha512-B+NQQngd/aDbcfW0zGLis3wTLDeHTeTYMl/mGKQH+HwdPaRCKI1wEtaXaOYVJXkP2FeThocPevB8gLwNlPQUUw== +"@angular/core@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.19.tgz#ce8b2a0df48612b3d9bfefe8098a1018a3186406" + integrity sha512-SYnwW+q51bQoPtGFoGovm1P5GK9fMEXsG0lGaEAUapjskblAYyX7hLlM/jgueSojv2SjhqNF8aXR+gjHLhZVNA== dependencies: tslib "^2.3.0" -"@angular/forms@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.18.tgz#ebd249a381dd3f6e5af3f760d7c3a8a6a76531ac" - integrity sha512-x6/99LfxolyZIFUL3Wr0OrtuXHEDwEz/rwx+WzE7NL+n35yO40t3kp0Sn5uMFwI94i91QZJmXHltMpZhrVLuYg== +"@angular/forms@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.19.tgz#179fa22f6e2daf3fe58c400d3e3a7c10647f0ebc" + integrity sha512-WJotd+Lhl4FG2b0K+aQNyQDHhR515zKCuphjiUqEW7sifWrOQxANLKzPBngGrH75ayANFgPaDf7U3ZRIoblcQA== dependencies: tslib "^2.3.0" -"@angular/language-service@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.18.tgz#b0c8c9bcf085907765e3cb7fbf0e14e79259e3c0" - integrity sha512-V1ZBqeTtZYH9H8/G1qCw6gafsJmhMIMFjLX0Hv2KpTpmfK9nxIHPEVnshr3xT+qKYJIrMV/cU5YOzInEapLpuQ== +"@angular/language-service@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.19.tgz#ba9e04a35717d948ff972476486eae2ea25a109d" + integrity sha512-9J0XrAKXInz11KKyNMrMZmn2NSjVbxzt/DsAumbrzzixeZwiY7vDy2Kqw/LLFLi7IlfMQ/gznz/mCVVgUWI5Gg== "@angular/material@20.2.14": version "20.2.14" @@ -448,39 +448,34 @@ dependencies: tslib "^2.3.0" -"@angular/platform-browser-dynamic@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.18.tgz#c4c633fcc4782e1a361a0904d2bc3f180bbf0f81" - integrity sha512-NyTobOGYVzGmPmtI+3lxMzxi0TbLq4SRNQ2ENEJAt6k2JnMmHBm483ppLRAM47nGlDdiraW0IX93EtYYNkiK3g== +"@angular/platform-browser-dynamic@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.19.tgz#b13cb94dc39be24e22d70712605ca879aa5e83b2" + integrity sha512-OgErw7wjcC+8yKF5h99hJq8x+tvc091wThfmdL5YC+U3HgRmUaNZFgB/jR7cb/NeeeC42QW5Vc0qoUTC9rMnLQ== dependencies: tslib "^2.3.0" -"@angular/platform-browser@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.18.tgz#33b5d5cdddc4a0f60a9ddf886b941587a348d304" - integrity sha512-q6s5rEN1yYazpHYp+k4pboXRzMsRB9auzTRBEhyXSGYxqzrnn3qHN0DqgsLC9WAdyhCgnIEMFA8kRT+W277DqQ== +"@angular/platform-browser@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.19.tgz#4bffa36591d9ba5adc89a1b48952fdfd982d2f30" + integrity sha512-TRZfatH1B/kreDwFRwtpLEurJQ6044qh6DWpvxzTbugaG5otLQJKTk+1z81/KsJwQqc1+24v+yuywc1LM7aq7w== dependencies: tslib "^2.3.0" -"@angular/router@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.18.tgz#276fe53975ceb858f4e76405f8219fbe5cb20204" - integrity sha512-3CWejsEYr+ze+ktvWN/qHdyq5WLrj96QZpGYJyxh1pchIcpMPE9MmLpdjf0CUrWYB7g/85u0Geq/xsz72JrGng== +"@angular/router@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.19.tgz#2346ff945a8039194ba921a473a8f4565a92b949" + integrity sha512-qHrMniHOsCJ4neZmcQVodjutJilyXAXk7EhLa931QyL0qyVKVomv6E0I3UFzRaC3ZeHc+hzBdU6C6bvMFKTl1g== dependencies: tslib "^2.3.0" -"@antfu/install-pkg@^0.4.0": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@antfu/install-pkg/-/install-pkg-0.4.1.tgz#d1d7f3be96ecdb41581629cafe8626d1748c0cf1" - integrity sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw== +"@antfu/install-pkg@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz#78fa036be1a6081b5a77a5cf59f50c7752b6ba26" + integrity sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ== dependencies: - package-manager-detector "^0.2.0" - tinyexec "^0.3.0" - -"@antfu/utils@^0.7.10": - version "0.7.10" - resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d" - integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww== + package-manager-detector "^1.3.0" + tinyexec "^1.0.1" "@auth0/angular-jwt@^5.2.0": version "5.2.0" @@ -1324,42 +1319,40 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" -"@braintree/sanitize-url@^7.0.1": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.0.tgz#048e48aab4f1460e3121e22aa62459d16653dc85" - integrity sha512-o+UlMLt49RvtCASlOMW0AkHnabN9wR9rwCCherxO0yG4Npy34GkvrAqdXQvrhNs+jh+gkK8gB8Lf05qL/O7KWg== +"@braintree/sanitize-url@^7.1.1": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz#ca2035b0fefe956a8676ff0c69af73e605fcd81f" + integrity sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA== -"@chevrotain/cst-dts-gen@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz#5e0863cc57dc45e204ccfee6303225d15d9d4783" - integrity sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ== +"@chevrotain/cst-dts-gen@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz#ec068e1e83c5fdad69d81773556cae97f0b5dcdb" + integrity sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg== dependencies: - "@chevrotain/gast" "11.0.3" - "@chevrotain/types" "11.0.3" - lodash-es "4.17.21" + "@chevrotain/gast" "12.0.0" + "@chevrotain/types" "12.0.0" -"@chevrotain/gast@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-11.0.3.tgz#e84d8880323fe8cbe792ef69ce3ffd43a936e818" - integrity sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q== +"@chevrotain/gast@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-12.0.0.tgz#0e0cbf8eee01c7a4449b9caf19e5f3834dba2c35" + integrity sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ== dependencies: - "@chevrotain/types" "11.0.3" - lodash-es "4.17.21" + "@chevrotain/types" "12.0.0" -"@chevrotain/regexp-to-ast@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz#11429a81c74a8e6a829271ce02fc66166d56dcdb" - integrity sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA== +"@chevrotain/regexp-to-ast@12.0.0", "@chevrotain/regexp-to-ast@~12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz#a90bc4b4f5337a883a88dddd0cca7c38cfe66a7a" + integrity sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA== -"@chevrotain/types@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-11.0.3.tgz#f8a03914f7b937f594f56eb89312b3b8f1c91848" - integrity sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ== +"@chevrotain/types@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-12.0.0.tgz#a762b5c2b4f07496b56c93c30ce224b3637cc2c8" + integrity sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA== -"@chevrotain/utils@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-11.0.3.tgz#e39999307b102cff3645ec4f5b3665f5297a2224" - integrity sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ== +"@chevrotain/utils@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-12.0.0.tgz#9aab2055df43d0bb55919eaca76a9cda45e52b89" + integrity sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA== "@cspotcode/source-map-support@^0.8.0": version "0.8.1" @@ -1389,135 +1382,135 @@ resolved "https://registry.yarnpkg.com/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz#fe541a68aa080255f798c8561714ac8fad72cdd5" integrity sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g== -"@esbuild/aix-ppc64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz#bef96351f16520055c947aba28802eede3c9e9a9" - integrity sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA== - -"@esbuild/android-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz#d2e70be7d51a529425422091e0dcb90374c1546c" - integrity sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg== - -"@esbuild/android-arm@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.9.tgz#d2a753fe2a4c73b79437d0ba1480e2d760097419" - integrity sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ== - -"@esbuild/android-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.9.tgz#5278836e3c7ae75761626962f902a0d55352e683" - integrity sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw== - -"@esbuild/darwin-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz#f1513eaf9ec8fa15dcaf4c341b0f005d3e8b47ae" - integrity sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg== - -"@esbuild/darwin-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz#e27dbc3b507b3a1cea3b9280a04b8b6b725f82be" - integrity sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ== - -"@esbuild/freebsd-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz#364e3e5b7a1fd45d92be08c6cc5d890ca75908ca" - integrity sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q== - -"@esbuild/freebsd-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz#7c869b45faeb3df668e19ace07335a0711ec56ab" - integrity sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg== - -"@esbuild/linux-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz#48d42861758c940b61abea43ba9a29b186d6cb8b" - integrity sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw== - -"@esbuild/linux-arm@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz#6ce4b9cabf148274101701d112b89dc67cc52f37" - integrity sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw== - -"@esbuild/linux-ia32@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz#207e54899b79cac9c26c323fc1caa32e3143f1c4" - integrity sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A== - -"@esbuild/linux-loong64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz#0ba48a127159a8f6abb5827f21198b999ffd1fc0" - integrity sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ== - -"@esbuild/linux-mips64el@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz#a4d4cc693d185f66a6afde94f772b38ce5d64eb5" - integrity sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA== - -"@esbuild/linux-ppc64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz#0f5805c1c6d6435a1dafdc043cb07a19050357db" - integrity sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w== - -"@esbuild/linux-riscv64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz#6776edece0f8fca79f3386398b5183ff2a827547" - integrity sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg== - -"@esbuild/linux-s390x@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz#3f6f29ef036938447c2218d309dc875225861830" - integrity sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA== - -"@esbuild/linux-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz#831fe0b0e1a80a8b8391224ea2377d5520e1527f" - integrity sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg== - -"@esbuild/netbsd-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz#06f99d7eebe035fbbe43de01c9d7e98d2a0aa548" - integrity sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q== - -"@esbuild/netbsd-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz#db99858e6bed6e73911f92a88e4edd3a8c429a52" - integrity sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g== - -"@esbuild/openbsd-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz#afb886c867e36f9d86bb21e878e1185f5d5a0935" - integrity sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ== - -"@esbuild/openbsd-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz#30855c9f8381fac6a0ef5b5f31ac6e7108a66ecf" - integrity sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA== - -"@esbuild/openharmony-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz#2f2144af31e67adc2a8e3705c20c2bd97bd88314" - integrity sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg== - -"@esbuild/sunos-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz#69b99a9b5bd226c9eb9c6a73f990fddd497d732e" - integrity sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw== - -"@esbuild/win32-arm64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz#d789330a712af916c88325f4ffe465f885719c6b" - integrity sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ== - -"@esbuild/win32-ia32@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz#52fc735406bd49688253e74e4e837ac2ba0789e3" - integrity sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww== - -"@esbuild/win32-x64@0.25.9": - version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" - integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== +"@esbuild/aix-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" + integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== + +"@esbuild/android-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" + integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== + +"@esbuild/android-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" + integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== + +"@esbuild/android-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" + integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== + +"@esbuild/darwin-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" + integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== + +"@esbuild/darwin-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" + integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== + +"@esbuild/freebsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" + integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== + +"@esbuild/freebsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" + integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== + +"@esbuild/linux-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" + integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== + +"@esbuild/linux-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" + integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== + +"@esbuild/linux-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" + integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== + +"@esbuild/linux-loong64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" + integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== + +"@esbuild/linux-mips64el@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" + integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== + +"@esbuild/linux-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" + integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== + +"@esbuild/linux-riscv64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" + integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== + +"@esbuild/linux-s390x@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" + integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== + +"@esbuild/linux-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" + integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== + +"@esbuild/netbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" + integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== + +"@esbuild/netbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" + integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== + +"@esbuild/openbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" + integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== + +"@esbuild/openbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" + integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== + +"@esbuild/openharmony-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" + integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== + +"@esbuild/sunos-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" + integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== + +"@esbuild/win32-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" + integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== + +"@esbuild/win32-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" + integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== + +"@esbuild/win32-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" + integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" @@ -1599,17 +1592,17 @@ dependencies: tslib "^2.3.0" -"@geoman-io/leaflet-geoman-free@2.18.3": - version "2.18.3" - resolved "https://registry.yarnpkg.com/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.18.3.tgz#a41489920b175931fba2a1e8e81347f9e3be5481" - integrity sha512-XzxSKRk2UJUVeGiOt1jU2hyo412Qee1Q0Xsfw4A2r8EoUIo48XKSWfusYe7E53fSPr0aYgZxPevnFdcUXimpdA== +"@geoman-io/leaflet-geoman-free@2.19.3": + version "2.19.3" + resolved "https://registry.yarnpkg.com/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.19.3.tgz#42ea4d10be3a76c64376cdd8736b191dd0c5ae2d" + integrity sha512-HjbEpfAEUs0NyI1Dhvz3SMVG6m0pAN/1Eo0tRKsz9cpaROTrFtmJGY22swEir1Uj/8IeGF1NJId38C5Fu+nZGQ== dependencies: - "@turf/boolean-contains" "^6.5.0" - "@turf/kinks" "^6.5.0" - "@turf/line-intersect" "^6.5.0" - "@turf/line-split" "^6.5.0" - lodash "4.17.21" - polyclip-ts "^0.16.5" + "@turf/boolean-contains" "^7.3.3" + "@turf/kinks" "^7.3.3" + "@turf/line-intersect" "^7.3.3" + "@turf/line-split" "^7.3.3" + lodash "4.18.1" + polyclip-ts "^0.16.8" "@hono/node-server@^1.19.9": version "1.19.11" @@ -1649,18 +1642,14 @@ resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== -"@iconify/utils@^2.1.32": - version "2.1.33" - resolved "https://registry.yarnpkg.com/@iconify/utils/-/utils-2.1.33.tgz#cbf7242a52fd0ec58c42d37d28e4406b5327e8c0" - integrity sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw== +"@iconify/utils@^3.0.2": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@iconify/utils/-/utils-3.1.0.tgz#fb41882915f97fee6f91a2fbb8263e8772ca0438" + integrity sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw== dependencies: - "@antfu/install-pkg" "^0.4.0" - "@antfu/utils" "^0.7.10" + "@antfu/install-pkg" "^1.1.0" "@iconify/types" "^2.0.0" - debug "^4.3.6" - kolorist "^1.8.0" - local-pkg "^0.5.0" - mlly "^1.7.1" + mlly "^1.8.0" "@inquirer/ansi@^1.0.2": version "1.0.2" @@ -2165,12 +2154,12 @@ resolved "https://registry.yarnpkg.com/@mdi/svg/-/svg-7.4.47.tgz#f8e5516aae129764a76d1bb2f27e55bee03e6e90" integrity sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw== -"@mermaid-js/parser@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.3.0.tgz#7a28714599f692f93df130b299fa1aadc9f9c8ab" - integrity sha512-HsvL6zgE5sUPGgkIDlmAWR1HTNHz2Iy11BAWPTa4Jjabkpguy4Ze2gzfLrg6pdRuBvFwgUYyxiaNqZwrEEXepA== +"@mermaid-js/parser@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-1.1.0.tgz#8f96c35ddab34a1b12af58f2c59f5abb7d4743fc" + integrity sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw== dependencies: - langium "3.0.0" + langium "^4.0.0" "@messageformat/core@^3.4.0": version "3.4.0" @@ -2397,10 +2386,10 @@ dependencies: tslib "^2.0.0" -"@ngtools/webpack@20.3.22": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.22.tgz#65c7eefb187516f00d2268c60613294cf8d55028" - integrity sha512-EgOiRjYpNG5Mu/WAhMwQrAB1BBwWrApbYm2hTU9KjaxOZvtWHjeFfsiULgd1T76GpiF4t3Iw1GtNljzgD6fT/A== +"@ngtools/webpack@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.24.tgz#8e344ebaea46d7e669220f02a33bb61826bb35f6" + integrity sha512-PIP9hFVF6OOmDxG0s6vX7cHm/6wwWK8jXd6e1I/CewR0zpVPtR1vxhhw9CrY4VEUCFSL6x2NuW/U3cI7LU+Z1Q== "@ngx-translate/core@^17.0.0": version "17.0.0" @@ -2751,13 +2740,13 @@ resolved "https://registry.yarnpkg.com/@sanity/diff-match-patch/-/diff-match-patch-3.2.0.tgz#7ce587273f7372a146308cb1075ba26177d42cdb" integrity sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww== -"@schematics/angular@20.3.22": - version "20.3.22" - resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.22.tgz#acd7a990148a99f04793ee2e92a56694667790a4" - integrity sha512-wXTdFaPIBnSSNj/m0kclvPCYQOc2EGTQN1+Q3j9RIghS9gKgPxI1unSfgieJldZWKzcl8+WdB2zUuDzE7tEshQ== +"@schematics/angular@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.24.tgz#1cef967a08a55336b296dfd0e2c5207dc1c43226" + integrity sha512-GNB8zI8Lz0rJl4Q7FH4Y8ZmRpODkNDKGxWObfZ39POgiyr3CtT5sMRTQq1lWRWTlZeV8uD51DvW/EsAsbaS4HA== dependencies: - "@angular-devkit/core" "20.3.22" - "@angular-devkit/schematics" "20.3.22" + "@angular-devkit/core" "20.3.24" + "@angular-devkit/schematics" "20.3.24" jsonc-parser "3.3.1" "@sigstore/bundle@^4.0.0": @@ -2871,181 +2860,167 @@ "@tufjs/canonical-json" "2.0.0" minimatch "^10.1.1" -"@turf/bbox@*": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-7.1.0.tgz#45a9287c084f7b79577ee88b7b539d83562b923b" - integrity sha512-PdWPz9tW86PD78vSZj2fiRaB8JhUHy6piSa/QXb83lucxPK+HTAdzlDQMTKj5okRCU8Ox/25IR2ep9T8NdopRA== +"@turf/bbox@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-7.3.5.tgz#bf93e0a81aa98ca8809bea1bfa6cdfd03ce2470a" + integrity sha512-oG1ya/HtBjAIg4TimbWx+nOYPbY0bCvt82Bq8tm6sBw3qqtbOyRSfDz79Sq90TnH7DXJprJ1qnVGKNtZ6jemfw== dependencies: - "@turf/helpers" "^7.1.0" - "@turf/meta" "^7.1.0" + "@turf/helpers" "7.3.5" + "@turf/meta" "7.3.5" "@types/geojson" "^7946.0.10" - tslib "^2.6.2" - -"@turf/bbox@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-6.5.0.tgz#bec30a744019eae420dac9ea46fb75caa44d8dc5" - integrity sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw== - dependencies: - "@turf/helpers" "^6.5.0" - "@turf/meta" "^6.5.0" - -"@turf/bearing@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/bearing/-/bearing-6.5.0.tgz#462a053c6c644434bdb636b39f8f43fb0cd857b0" - integrity sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A== - dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - -"@turf/boolean-contains@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz#f802e7432fb53109242d5bf57393ef2f53849bbf" - integrity sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ== - dependencies: - "@turf/bbox" "^6.5.0" - "@turf/boolean-point-in-polygon" "^6.5.0" - "@turf/boolean-point-on-line" "^6.5.0" - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + tslib "^2.8.1" + +"@turf/boolean-contains@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-7.3.5.tgz#8f3d3bd996b98a23953c42a5db932f17bcb98b68" + integrity sha512-P4JUAHgvJkD+8ybQ6d1OHp9TBsGsjJxF5lWeXJgp0k4+Hd/D0CVy4/mhLkZdNa6QdljVdwNcfU0CTqy1WsSQig== + dependencies: + "@turf/bbox" "7.3.5" + "@turf/boolean-point-in-polygon" "7.3.5" + "@turf/boolean-point-on-line" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/line-split" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/boolean-point-in-polygon@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz#6d2e9c89de4cd2e4365004c1e51490b7795a63cf" - integrity sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A== +"@turf/boolean-point-in-polygon@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz#4416dbde721225153590cc2204f614e44f388b55" + integrity sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@types/geojson" "^7946.0.10" + point-in-polygon-hao "^1.1.0" + tslib "^2.8.1" -"@turf/boolean-point-on-line@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz#a8efa7bad88760676f395afb9980746bc5b376e9" - integrity sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ== +"@turf/boolean-point-on-line@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-7.3.5.tgz#9ae059b836240c5e4619c5632682db8acbe511b6" + integrity sha512-TuWfrAT63W43BDzgYc94UzQ5/PjF1aTnh4AIzmQwez1YnimShYcOTwo8OIHzDaB6gbbvFsfxYMuOA5JOp942Kg== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/destination@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/destination/-/destination-6.5.0.tgz#30a84702f9677d076130e0440d3223ae503fdae1" - integrity sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ== +"@turf/distance@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-7.3.5.tgz#3aa16a1fde30e5cf4cf40b7e497be8cc5d6bbaaf" + integrity sha512-uQAC63zg/l91KUxzfhqio7Ii3+UXTrPOVJScIdRj6EO6+9XHI4kC+AdyIS4cPAv14sZfJLIBxzMnzcGrss+kEA== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/distance@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-6.5.0.tgz#21f04d5f86e864d54e2abde16f35c15b4f36149a" - integrity sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg== +"@turf/geojson-rbush@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/geojson-rbush/-/geojson-rbush-7.3.5.tgz#32e38b0b85b4dcf9af5aa433ddbb2e2702048e0a" + integrity sha512-30/hQqc+ErnlcavvDdxGfgm8VtsJDEzSYpf3mPqYxOyI976l49T6+1jCQD5xKswml6o8zZAaTSe6ZcSKF+SCNw== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - -"@turf/helpers@6.x", "@turf/helpers@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.5.0.tgz#f79af094bd6b8ce7ed2bd3e089a8493ee6cae82e" - integrity sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw== + "@turf/bbox" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + rbush "^3.0.1" + tslib "^2.8.1" -"@turf/helpers@^7.1.0": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.1.0.tgz#eb734e291c9c205822acdd289fe20e91c3cb1641" - integrity sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ== +"@turf/helpers@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.3.5.tgz#051928c03cdf9ffcc7ae36581c317afc49bfd999" + integrity sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg== dependencies: "@types/geojson" "^7946.0.10" - tslib "^2.6.2" + tslib "^2.8.1" -"@turf/invariant@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-6.5.0.tgz#970afc988023e39c7ccab2341bd06979ddc7463f" - integrity sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg== +"@turf/invariant@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-7.3.5.tgz#5619d0e0ef3755e2be69855bad47e10158a1820b" + integrity sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg== dependencies: - "@turf/helpers" "^6.5.0" + "@turf/helpers" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/kinks@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/kinks/-/kinks-6.5.0.tgz#80e7456367535365012f658cf1a988b39a2c920b" - integrity sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ== +"@turf/kinks@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/kinks/-/kinks-7.3.5.tgz#746c2295dd8e7ddc8822d13fc6b9ae8d4148babc" + integrity sha512-dPW8d4vs1v8WMobjyq/TVqajjPwkMsl94IF58yp1UYlmJDQrW4iNRUmI9fFzww+fl7epCKNwY+jZhXf1DRi93w== dependencies: - "@turf/helpers" "^6.5.0" + "@turf/helpers" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/line-intersect@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-6.5.0.tgz#dea48348b30c093715d2195d2dd7524aee4cf020" - integrity sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA== +"@turf/line-intersect@7.3.5", "@turf/line-intersect@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-7.3.5.tgz#841f8f713218cbd4532269754e3e1f6a15d4e374" + integrity sha512-2Cl4oPsjaDdfIwz/5IRDdG2fNdfp3W6atICm81vnzl/GwURoVP+CLjXJ64QWWzpzIbgX2XprJQTmamByDt5MDw== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/line-segment" "^6.5.0" - "@turf/meta" "^6.5.0" - geojson-rbush "3.x" + "@turf/helpers" "7.3.5" + "@types/geojson" "^7946.0.10" + sweepline-intersections "^1.5.0" + tslib "^2.8.1" -"@turf/line-segment@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/line-segment/-/line-segment-6.5.0.tgz#ee73f3ffcb7c956203b64ed966d96af380a4dd65" - integrity sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw== +"@turf/line-segment@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/line-segment/-/line-segment-7.3.5.tgz#275f03fd86640fce44b40d6806b95dc842801156" + integrity sha512-TM1aCu7utM6fllAEHO8PNqBJZ/uoFJVNp2A0YI7FyWN928hPbacsvNtLeVz/Kq1ZbeqQ1ZIKRxo9FdVjaj8hGg== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/meta" "^6.5.0" - -"@turf/line-split@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/line-split/-/line-split-6.5.0.tgz#116d7fbf714457878225187f5820ef98db7b02c2" - integrity sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw== - dependencies: - "@turf/bbox" "^6.5.0" - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/line-intersect" "^6.5.0" - "@turf/line-segment" "^6.5.0" - "@turf/meta" "^6.5.0" - "@turf/nearest-point-on-line" "^6.5.0" - "@turf/square" "^6.5.0" - "@turf/truncate" "^6.5.0" - geojson-rbush "3.x" - -"@turf/meta@6.x", "@turf/meta@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.5.0.tgz#b725c3653c9f432133eaa04d3421f7e51e0418ca" - integrity sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA== - dependencies: - "@turf/helpers" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-split@7.3.5", "@turf/line-split@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/line-split/-/line-split-7.3.5.tgz#63aa7c5d3fc606db7c4beff33dccc8786fc9c07b" + integrity sha512-GEuy+LdbbaqtYjHk/i1G8sK51wfCdPqTO8uH0dJZ6WlcIcZQfRcKKI4ksFm7NkVyfmw8gXWbpMJD8lO380GFBQ== + dependencies: + "@turf/bbox" "7.3.5" + "@turf/geojson-rbush" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/line-intersect" "7.3.5" + "@turf/line-segment" "7.3.5" + "@turf/meta" "7.3.5" + "@turf/nearest-point-on-line" "7.3.5" + "@turf/truncate" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/meta@^7.1.0": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.1.0.tgz#b2af85afddd0ef08aeae8694a12370a4f06b6d13" - integrity sha512-ZgGpWWiKz797Fe8lfRj7HKCkGR+nSJ/5aKXMyofCvLSc2PuYJs/qyyifDPWjASQQCzseJ7AlF2Pc/XQ/3XkkuA== +"@turf/meta@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.3.5.tgz#c498a6f1e8603e014e8bfbc9a4b2760b6219edb9" + integrity sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg== dependencies: - "@turf/helpers" "^7.1.0" + "@turf/helpers" "7.3.5" "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/nearest-point-on-line@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz#8e1cd2cdc0b5acaf4c8d8b3b33bb008d3cb99e7b" - integrity sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg== - dependencies: - "@turf/bearing" "^6.5.0" - "@turf/destination" "^6.5.0" - "@turf/distance" "^6.5.0" - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/line-intersect" "^6.5.0" - "@turf/meta" "^6.5.0" - -"@turf/square@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/square/-/square-6.5.0.tgz#ab43eef99d39c36157ab5b80416bbeba1f6b2122" - integrity sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ== +"@turf/nearest-point-on-line@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-7.3.5.tgz#e74515e017feff2c8d7c208b634a5cbfdbebe998" + integrity sha512-MZn6OkEFZpjS6BNUANfqiHMIbQSivu7TNji3a+OAIrnPJ71vp8cbz0N2aVEa5M7I8ipvxoxAPIV3eqg3h280Vg== dependencies: - "@turf/distance" "^6.5.0" - "@turf/helpers" "^6.5.0" + "@turf/distance" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/truncate@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/truncate/-/truncate-6.5.0.tgz#c3a16cad959f1be1c5156157d5555c64b19185d8" - integrity sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg== +"@turf/truncate@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/truncate/-/truncate-7.3.5.tgz#d57a856994e2928e798fc7929e42cc2890d1b201" + integrity sha512-Qx2iv3KIqKuDAUduMfaJ5fFegEWBeRve5zePalRevS16bMUqEX+jnKPK9fWGyUuPqT61qP1Kybz0PTWPbUbljQ== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/meta" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" "@types/ace-diff@^2.1.4": version "2.1.4" @@ -3087,6 +3062,216 @@ dependencies: "@types/node" "*" +"@types/d3-array@*": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" + integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw== + +"@types/d3-axis@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-3.0.6.tgz#e760e5765b8188b1defa32bc8bb6062f81e4c795" + integrity sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-brush@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.6.tgz#c2f4362b045d472e1b186cdbec329ba52bdaee6c" + integrity sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-chord@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-chord/-/d3-chord-3.0.6.tgz#1706ca40cf7ea59a0add8f4456efff8f8775793d" + integrity sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-contour@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-contour/-/d3-contour-3.0.6.tgz#9ada3fa9c4d00e3a5093fed0356c7ab929604231" + integrity sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg== + dependencies: + "@types/d3-array" "*" + "@types/geojson" "*" + +"@types/d3-delaunay@*": + version "6.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz#185c1a80cc807fdda2a3fe960f7c11c4a27952e1" + integrity sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw== + +"@types/d3-dispatch@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz#ef004d8a128046cfce434d17182f834e44ef95b2" + integrity sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA== + +"@types/d3-drag@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-dsv@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz#0a351f996dc99b37f4fa58b492c2d1c04e3dac17" + integrity sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g== + +"@types/d3-ease@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-fetch@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz#c04a2b4f23181aa376f30af0283dbc7b3b569980" + integrity sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@*": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.10.tgz#6dc8fc6e1f35704f3b057090beeeb7ac674bff1a" + integrity sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw== + +"@types/d3-format@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.4.tgz#b1e4465644ddb3fdf3a263febb240a6cd616de90" + integrity sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g== + +"@types/d3-geo@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.1.0.tgz#b9e56a079449174f0a2c8684a9a4df3f60522440" + integrity sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@*": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz#6023fb3b2d463229f2d680f9ac4b47466f71f17b" + integrity sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg== + +"@types/d3-interpolate@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" + integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== + +"@types/d3-polygon@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz#dfae54a6d35d19e76ac9565bcb32a8e54693189c" + integrity sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA== + +"@types/d3-quadtree@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz#d4740b0fe35b1c58b66e1488f4e7ed02952f570f" + integrity sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg== + +"@types/d3-random@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.3.tgz#ed995c71ecb15e0cd31e22d9d5d23942e3300cfb" + integrity sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ== + +"@types/d3-scale-chromatic@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" + integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ== + +"@types/d3-scale@*": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" + integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== + dependencies: + "@types/d3-time" "*" + +"@types/d3-selection@*": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3" + integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w== + +"@types/d3-shape@*": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" + integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time-format@*": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz#d6bc1e6b6a7db69cccfbbdd4c34b70632d9e9db2" + integrity sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg== + +"@types/d3-time@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" + integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== + +"@types/d3-timer@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + +"@types/d3-transition@*": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706" + integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@*": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/d3@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.4.3.tgz#d4550a85d08f4978faf0a4c36b848c61eaac07e2" + integrity sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww== + dependencies: + "@types/d3-array" "*" + "@types/d3-axis" "*" + "@types/d3-brush" "*" + "@types/d3-chord" "*" + "@types/d3-color" "*" + "@types/d3-contour" "*" + "@types/d3-delaunay" "*" + "@types/d3-dispatch" "*" + "@types/d3-drag" "*" + "@types/d3-dsv" "*" + "@types/d3-ease" "*" + "@types/d3-fetch" "*" + "@types/d3-force" "*" + "@types/d3-format" "*" + "@types/d3-geo" "*" + "@types/d3-hierarchy" "*" + "@types/d3-interpolate" "*" + "@types/d3-path" "*" + "@types/d3-polygon" "*" + "@types/d3-quadtree" "*" + "@types/d3-random" "*" + "@types/d3-scale" "*" + "@types/d3-scale-chromatic" "*" + "@types/d3-selection" "*" + "@types/d3-shape" "*" + "@types/d3-time" "*" + "@types/d3-time-format" "*" + "@types/d3-timer" "*" + "@types/d3-transition" "*" + "@types/d3-zoom" "*" + "@types/eslint-scope@^3.7.7": version "3.7.7" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" @@ -3177,11 +3362,6 @@ resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== -"@types/geojson@7946.0.8": - version "7946.0.8" - resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.8.tgz#30744afdb385e2945e22f3b033f897f76b1f12ca" - integrity sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA== - "@types/hammerjs@^2.0.45": version "2.0.45" resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.45.tgz#ffa764bb68a66c08db6efb9c816eb7be850577b1" @@ -3384,6 +3564,11 @@ dependencies: "@types/jquery" "*" +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/ws@^8.5.10": version "8.5.12" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.12.tgz#619475fe98f35ccca2a2f6c137702d85ec247b7e" @@ -3487,6 +3672,14 @@ "@typescript-eslint/types" "8.54.0" eslint-visitor-keys "^4.2.1" +"@upsetjs/venn.js@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz#3be192038cdda927aa4f8b22ab51af82abf47f34" + integrity sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw== + optionalDependencies: + d3-selection "^3.0.0" + d3-transition "^3.0.1" + "@vitejs/plugin-basic-ssl@2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz#c70d2a922bc437f154089d7ef0505db4b383eb7b" @@ -3683,10 +3876,10 @@ acorn-walk@^8.1.1: dependencies: acorn "^8.11.0" -acorn@^8.11.0, acorn@^8.11.3, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.4.1: - version "8.15.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" - integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== +acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.1: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== adjust-sourcemap-loader@^4.0.0: version "4.0.0" @@ -4305,24 +4498,23 @@ chardet@^2.1.1: resolved "https://registry.yarnpkg.com/chardet/-/chardet-2.1.1.tgz#5c75593704a642f71ee53717df234031e65373c8" integrity sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ== -chevrotain-allstar@~0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz#b7412755f5d83cc139ab65810cdb00d8db40e6ca" - integrity sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw== +chevrotain-allstar@~0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz#04e1429faca94a14d4572e0107c4865beac36298" + integrity sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA== dependencies: lodash-es "^4.17.21" -chevrotain@~11.0.3: - version "11.0.3" - resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-11.0.3.tgz#88ffc1fb4b5739c715807eaeedbbf200e202fc1b" - integrity sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw== +chevrotain@~12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-12.0.0.tgz#8ebefe0a0516b1b314a8d9c7f4e948a509098d1c" + integrity sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ== dependencies: - "@chevrotain/cst-dts-gen" "11.0.3" - "@chevrotain/gast" "11.0.3" - "@chevrotain/regexp-to-ast" "11.0.3" - "@chevrotain/types" "11.0.3" - "@chevrotain/utils" "11.0.3" - lodash-es "4.17.21" + "@chevrotain/cst-dts-gen" "12.0.0" + "@chevrotain/gast" "12.0.0" + "@chevrotain/regexp-to-ast" "12.0.0" + "@chevrotain/types" "12.0.0" + "@chevrotain/utils" "12.0.0" chokidar@^3.6.0: version "3.6.0" @@ -4538,10 +4730,10 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -confbox@^0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.7.tgz#ccfc0a2bcae36a84838e83a3b7f770fb17d6c579" - integrity sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA== +confbox@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" + integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== config-chain@^1.1.13: version "1.1.13" @@ -4746,10 +4938,10 @@ cytoscape-fcose@^2.2.0: dependencies: cose-base "^2.2.0" -cytoscape@^3.29.2: - version "3.30.2" - resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.30.2.tgz#94149707fb6547a55e3b44f03ffe232706212161" - integrity sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw== +cytoscape@^3.33.1: + version "3.33.2" + resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.33.2.tgz#3a58906b4002b7c237f54dfc9b971983757da791" + integrity sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw== "d3-array@1 - 2": version "2.12.1" @@ -4926,7 +5118,7 @@ d3-scale@4: d3-time "2.1.1 - 3" d3-time-format "2 - 4" -"d3-selection@2 - 3", d3-selection@3: +"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== @@ -4964,7 +5156,7 @@ d3-shape@^1.2.0: resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== -"d3-transition@2 - 3", d3-transition@3: +"d3-transition@2 - 3", d3-transition@3, d3-transition@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== @@ -4986,7 +5178,7 @@ d3-zoom@3: d3-selection "2 - 3" d3-transition "2 - 3" -d3@^7.8.2, d3@^7.9.0: +d3@^7.9.0: version "7.9.0" resolved "https://registry.yarnpkg.com/d3/-/d3-7.9.0.tgz#579e7acb3d749caf8860bd1741ae8d371070cd5d" integrity sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA== @@ -5022,12 +5214,12 @@ d3@^7.8.2, d3@^7.9.0: d3-transition "3" d3-zoom "3" -dagre-d3-es@7.0.10: - version "7.0.10" - resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz#19800d4be674379a3cd8c86a8216a2ac6827cadc" - integrity sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A== +dagre-d3-es@7.0.14: + version "7.0.14" + resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz#1272276e26457cf3b97dac569f8f0531ec33c377" + integrity sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg== dependencies: - d3 "^7.8.2" + d3 "^7.9.0" lodash-es "^4.17.21" data-uri-to-buffer@^4.0.0: @@ -5062,10 +5254,10 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" -dayjs@1.11.19, dayjs@^1.11.10, dayjs@^1.11.5: - version "1.11.19" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938" - integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== +dayjs@1.11.20, dayjs@^1.11.19, dayjs@^1.11.5: + version "1.11.20" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938" + integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ== debug@2.6.9: version "2.6.9" @@ -5256,10 +5448,12 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.0.11: - version "3.1.7" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.7.tgz#711a8c96479fb6ced93453732c160c3c72418a6a" - integrity sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ== +dompurify@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.0.tgz#b1fc33ebdadb373241621e0a30e4ad81573dfd0b" + integrity sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg== + optionalDependencies: + "@types/trusted-types" "^2.0.7" domutils@^3.2.2: version "3.2.2" @@ -5513,42 +5707,42 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -esbuild-wasm@0.25.9: - version "0.25.9" - resolved "https://registry.yarnpkg.com/esbuild-wasm/-/esbuild-wasm-0.25.9.tgz#70e15ff86d6d3e55b0e10817c826783f7ff6612a" - integrity sha512-Jpv5tCSwQg18aCqCRD3oHIX/prBhXMDapIoG//A+6+dV0e7KQMGFg85ihJ5T1EeMjbZjON3TqFy0VrGAnIHLDA== +esbuild-wasm@0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild-wasm/-/esbuild-wasm-0.28.0.tgz#e2e60b86ed19e0f6f5a1e6c1c722f5a811d7d5f7" + integrity sha512-5TRVKExcEmeMkccIZMzUq+Az6X2RoMAJyfl6SMMO1dMVhmvt0I2mx7gAb6zYi42n4d1ETcatFXazGKzA+aW7fg== -esbuild@0.25.9, esbuild@^0.25.0: - version "0.25.9" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.9.tgz#15ab8e39ae6cdc64c24ff8a2c0aef5b3fd9fa976" - integrity sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g== +esbuild@0.28.0, esbuild@^0.27.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== optionalDependencies: - "@esbuild/aix-ppc64" "0.25.9" - "@esbuild/android-arm" "0.25.9" - "@esbuild/android-arm64" "0.25.9" - "@esbuild/android-x64" "0.25.9" - "@esbuild/darwin-arm64" "0.25.9" - "@esbuild/darwin-x64" "0.25.9" - "@esbuild/freebsd-arm64" "0.25.9" - "@esbuild/freebsd-x64" "0.25.9" - "@esbuild/linux-arm" "0.25.9" - "@esbuild/linux-arm64" "0.25.9" - "@esbuild/linux-ia32" "0.25.9" - "@esbuild/linux-loong64" "0.25.9" - "@esbuild/linux-mips64el" "0.25.9" - "@esbuild/linux-ppc64" "0.25.9" - "@esbuild/linux-riscv64" "0.25.9" - "@esbuild/linux-s390x" "0.25.9" - "@esbuild/linux-x64" "0.25.9" - "@esbuild/netbsd-arm64" "0.25.9" - "@esbuild/netbsd-x64" "0.25.9" - "@esbuild/openbsd-arm64" "0.25.9" - "@esbuild/openbsd-x64" "0.25.9" - "@esbuild/openharmony-arm64" "0.25.9" - "@esbuild/sunos-x64" "0.25.9" - "@esbuild/win32-arm64" "0.25.9" - "@esbuild/win32-ia32" "0.25.9" - "@esbuild/win32-x64" "0.25.9" + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -6046,9 +6240,9 @@ flatted@^3.2.9: resolved "https://github.com/thingsboard/flot.git#c2734540477d8b261d04ee18d4d38af3b0ecb81b" follow-redirects@^1.0.0: - version "1.15.9" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" - integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + 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== font-awesome@^4.7.0: version "4.7.0" @@ -6166,17 +6360,6 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -geojson-rbush@3.x: - version "3.2.0" - resolved "https://registry.yarnpkg.com/geojson-rbush/-/geojson-rbush-3.2.0.tgz#8b543cf0d56f99b78faf1da52bb66acad6dfc290" - integrity sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w== - dependencies: - "@turf/bbox" "*" - "@turf/helpers" "6.x" - "@turf/meta" "6.x" - "@types/geojson" "7946.0.8" - rbush "^3.0.1" - geojson-vt@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/geojson-vt/-/geojson-vt-4.0.2.tgz#1162f6c7d61a0ba305b1030621e6e111f847828a" @@ -7206,10 +7389,10 @@ karma-source-map-support@1.4.0: dependencies: source-map-support "^0.5.5" -katex@^0.16.0, katex@^0.16.9: - version "0.16.11" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.11.tgz#4bc84d5584f996abece5f01c6ad11304276a33f5" - integrity sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ== +katex@^0.16.0, katex@^0.16.25: + version "0.16.45" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.45.tgz#ba60d39c54746b6b8d39ce0e7f6eace07143149c" + integrity sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA== dependencies: commander "^8.3.0" @@ -7242,21 +7425,17 @@ klaw-sync@^6.0.0: dependencies: graceful-fs "^4.1.11" -kolorist@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" - integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== - -langium@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/langium/-/langium-3.0.0.tgz#4938294eb57c59066ef955070ac4d0c917b26026" - integrity sha512-+Ez9EoiByeoTu/2BXmEaZ06iPNXM6thWJp02KfBO/raSMyCJ4jw7AkWWa+zBCTm0+Tw1Fj9FOxdqSskyN5nAwg== +langium@^4.0.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/langium/-/langium-4.2.2.tgz#d7409c23475d591ed6fc7d123c396e4fa4134e60" + integrity sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ== dependencies: - chevrotain "~11.0.3" - chevrotain-allstar "~0.3.0" + "@chevrotain/regexp-to-ast" "~12.0.0" + chevrotain "~12.0.0" + chevrotain-allstar "~0.4.1" vscode-languageserver "~9.0.1" vscode-languageserver-textdocument "~1.0.11" - vscode-uri "~3.0.8" + vscode-uri "~3.1.0" launch-editor@^2.6.1: version "2.9.1" @@ -7422,14 +7601,6 @@ loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" -local-pkg@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.5.0.tgz#093d25a346bae59a99f80e75f6e9d36d7e8c925c" - integrity sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg== - dependencies: - mlly "^1.4.2" - pkg-types "^1.0.3" - locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -7444,10 +7615,10 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@4.17.21, lodash-es@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash-es@^4.17.21, lodash-es@^4.17.23: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== lodash.camelcase@^4.3.0: version "4.3.0" @@ -7464,10 +7635,10 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@4.17.21, lodash@^4.17.14: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +lodash@4.18.1, lodash@^4.17.14: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== log-driver@1.2.7: version "1.2.7" @@ -7584,12 +7755,7 @@ maplibre-gl@5.2.0: tinyqueue "^3.0.0" vt-pbf "^3.1.3" -marked@^13.0.2: - version "13.0.3" - resolved "https://registry.yarnpkg.com/marked/-/marked-13.0.3.tgz#5c5b4a5d0198060c7c9bc6ef9420a7fed30f822d" - integrity sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA== - -marked@~16.4.2: +marked@^16.3.0, marked@~16.4.2: version "16.4.2" resolved "https://registry.yarnpkg.com/marked/-/marked-16.4.2.tgz#4959a64be6c486f0db7467ead7ce288de54290a3" integrity sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA== @@ -7650,29 +7816,31 @@ merge2@^1.3.0: integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== "mermaid@>= 10.6.0 < 12.0.0": - version "11.2.1" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.2.1.tgz#b168c6f862268f77a0d3559926b193926ddc60bc" - integrity sha512-F8TEaLVVyxTUmvKswVFyOkjPrlJA5h5vNR1f7ZnSWSpqxgEZG1hggtn/QCa7znC28bhlcrNh10qYaIiill7q4A== - dependencies: - "@braintree/sanitize-url" "^7.0.1" - "@iconify/utils" "^2.1.32" - "@mermaid-js/parser" "^0.3.0" - cytoscape "^3.29.2" + version "11.14.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.14.0.tgz#ce81b22bc10f3117ef7737406ef2d10ee1741769" + integrity sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g== + dependencies: + "@braintree/sanitize-url" "^7.1.1" + "@iconify/utils" "^3.0.2" + "@mermaid-js/parser" "^1.1.0" + "@types/d3" "^7.4.3" + "@upsetjs/venn.js" "^2.0.0" + cytoscape "^3.33.1" cytoscape-cose-bilkent "^4.1.0" cytoscape-fcose "^2.2.0" d3 "^7.9.0" d3-sankey "^0.12.3" - dagre-d3-es "7.0.10" - dayjs "^1.11.10" - dompurify "^3.0.11" - katex "^0.16.9" + dagre-d3-es "7.0.14" + dayjs "^1.11.19" + dompurify "^3.3.1" + katex "^0.16.25" khroma "^2.1.0" - lodash-es "^4.17.21" - marked "^13.0.2" + lodash-es "^4.17.23" + marked "^16.3.0" roughjs "^4.6.6" - stylis "^4.3.1" + stylis "^4.3.6" ts-dedent "^2.2.0" - uuid "^9.0.1" + uuid "^11.1.0" methods@~1.1.2: version "1.1.2" @@ -7818,15 +7986,15 @@ minizlib@^3.0.1, minizlib@^3.1.0: dependencies: minipass "^7.1.2" -mlly@^1.4.2, mlly@^1.7.1: - version "1.7.1" - resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.1.tgz#e0336429bb0731b6a8e887b438cbdae522c8f32f" - integrity sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA== +mlly@^1.7.4, mlly@^1.8.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.2.tgz#e7f7919a82d13b174405613117249a3f449d78bb" + integrity sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA== dependencies: - acorn "^8.11.3" - pathe "^1.1.2" - pkg-types "^1.1.1" - ufo "^1.5.3" + acorn "^8.16.0" + pathe "^2.0.3" + pkg-types "^1.3.1" + ufo "^1.6.3" moment-timezone@^0.6.0: version "0.6.0" @@ -8400,10 +8568,10 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== -package-manager-detector@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea" - integrity sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog== +package-manager-detector@^1.3.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz#70d0cf0aa02c877eeaf66c4d984ede0be9130734" + integrity sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA== pacote@21.0.4: version "21.0.4" @@ -8561,10 +8729,10 @@ path-to-regexp@~0.1.12: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d" integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA== -pathe@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" - integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== +pathe@^2.0.1, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== pbf@^3.2.1, pbf@^3.3.0: version "3.3.0" @@ -8616,20 +8784,27 @@ pkce-challenge@^5.0.0: resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ== -pkg-types@^1.0.3, pkg-types@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.2.0.tgz#d0268e894e93acff11a6279de147e83354ebd42d" - integrity sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA== +pkg-types@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" + integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== dependencies: - confbox "^0.1.7" - mlly "^1.7.1" - pathe "^1.1.2" + confbox "^0.1.8" + mlly "^1.7.4" + pathe "^2.0.1" pngjs@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== +point-in-polygon-hao@^1.1.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz#8662abdcc84bcca230cc3ecbb0b0ab1a306f1bd6" + integrity sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ== + dependencies: + robust-predicates "^3.0.2" + points-on-curve@0.2.0, points-on-curve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/points-on-curve/-/points-on-curve-0.2.0.tgz#7dbb98c43791859434284761330fa893cb81b4d1" @@ -8643,13 +8818,13 @@ points-on-path@^0.2.1: path-data-parser "0.1.0" points-on-curve "0.2.0" -polyclip-ts@^0.16.5: - version "0.16.5" - resolved "https://registry.yarnpkg.com/polyclip-ts/-/polyclip-ts-0.16.5.tgz#053e073e640449f1b1a1d88471f8758779d0b030" - integrity sha512-ZchnG0zGZReHgEo3EYzEUi6UmfQFFzNnj6AFU+gBm+IJJ4qG9gL4CwjtCV6oi/PittUPpJLiLJxcn/AgrCBO+g== +polyclip-ts@^0.16.8: + version "0.16.8" + resolved "https://registry.yarnpkg.com/polyclip-ts/-/polyclip-ts-0.16.8.tgz#503160d05e9d56380533aab0bc2dae835d6da5f9" + integrity sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ== dependencies: bignumber.js "^9.1.0" - splaytree-ts "^1.0.1" + splaytree-ts "^1.0.2" possible-typed-array-names@^1.0.0: version "1.0.0" @@ -9674,10 +9849,10 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" -splaytree-ts@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/splaytree-ts/-/splaytree-ts-1.0.1.tgz#4ddcfe2684da017d02b599d53d67f6d07a90745b" - integrity sha512-B+VzCm33/KEchi/fzT6/3NRHm8k5+Kf37SBQO3meHHS/tK2xBnIm4ZvusQ1wUpHgKMCCqEWgXnwFXAa1nD289g== +splaytree-ts@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/splaytree-ts/-/splaytree-ts-1.0.2.tgz#34963704587aff45eaa09c24713f552bbf56e8f0" + integrity sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA== split.js@^1.6.5: version "1.6.5" @@ -9832,10 +10007,10 @@ strip-json-comments@3.1.1, strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -stylis@^4.3.1: - version "4.3.4" - resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.4.tgz#ca5c6c4a35c4784e4e93a2a24dc4e9fa075250a4" - integrity sha512-osIBl6BGUmSfDkyH2mB7EFvCJntXDrLhKjHTRj/rK6xLH0yuPrHULDRQzKokSOD4VoorhtKpfcfW1GAntu8now== +stylis@^4.3.6: + version "4.4.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.4.0.tgz#c5846c9345f4bfc51bd0cbd7ca35a0744f485a5d" + integrity sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA== sucrase@^3.35.0: version "3.35.1" @@ -9883,6 +10058,13 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +sweepline-intersections@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz#85ab3629a291875926fae0acd508496430d8a647" + integrity sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ== + dependencies: + tinyqueue "^2.0.0" + systemjs@6.15.1: version "6.15.1" resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.15.1.tgz#74175b6810e27a79e1177d21db5f0e3057118cea" @@ -10013,10 +10195,10 @@ tinycolor2@^1.6.0: resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== -tinyexec@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.0.tgz#ed60cfce19c17799d4a241e06b31b0ec2bee69e6" - integrity sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg== +tinyexec@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.1.1.tgz#e1ff45dfa60d1dedb91b734956b78f6c2a3e821b" + integrity sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg== tinyglobby@0.2.14: version "0.2.14" @@ -10039,6 +10221,11 @@ tinymce@6.8.6, "tinymce@^7.0.0 || ^6.0.0 || ^5.5.0", tinymce@~6.8.6: resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-6.8.6.tgz#799e4f03eeb4399399dfdeb12ba17b3b91887adf" integrity sha512-++XYEs8lKWvZxDCjrr8Baiw7KiikraZ5JkLMg6EdnUVNKJui0IsrAADj5MsyUeFkcEryfn2jd3p09H7REvewyg== +tinyqueue@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08" + integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA== + tinyqueue@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-3.0.0.tgz#101ea761ccc81f979e29200929e78f1556e3661e" @@ -10142,7 +10329,7 @@ tslib@2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== -tslib@2.8.1, tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.8.1, tslib@~2.8.1: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.8.1, tslib@~2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -10260,10 +10447,10 @@ typical@^5.2.0: resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066" integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg== -ufo@^1.5.3: - version "1.5.4" - resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754" - integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ== +ufo@^1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.3.tgz#799666e4e88c122a9659805e30b9dc071c3aed4f" + integrity sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q== unbox-primitive@^1.1.0: version "1.1.0" @@ -10359,16 +10546,16 @@ utrie@^1.0.2: dependencies: base64-arraybuffer "^1.0.2" +uuid@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" + integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== + uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== - v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -10397,12 +10584,12 @@ vary@^1, vary@^1.1.2, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== -vite@7.1.11: - version "7.1.11" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.11.tgz#4d006746112fee056df64985191e846ebfb6007e" - integrity sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg== +vite@7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.2.tgz#cb041794d4c1395e28baea98198fd6e8f4b96b5c" + integrity sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg== dependencies: - esbuild "^0.25.0" + esbuild "^0.27.0" fdir "^6.5.0" picomatch "^4.0.3" postcss "^8.5.6" @@ -10441,10 +10628,10 @@ vscode-languageserver@~9.0.1: dependencies: vscode-languageserver-protocol "3.17.5" -vscode-uri@~3.0.8: - version "3.0.8" - resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" - integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== +vscode-uri@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" + integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== vt-pbf@^3.1.3: version "3.1.3"