Browse Source

Merge remote-tracking branch 'ce/rc' into view-for-active-edges

pull/13278/head
Yevhenii 1 year ago
parent
commit
42f892152f
  1. 63
      application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg
  2. 69
      application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg
  3. 74
      application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg
  4. 112
      application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg
  5. 75
      application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg
  6. 71
      application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg
  7. 63
      application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg
  8. 118
      application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg
  9. 119
      application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg
  10. 69
      application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg
  11. 63
      application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg
  12. 69
      application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg
  13. 115
      application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg
  14. 2
      application/src/main/data/json/system/widget_bundles/high_performance_scada_energy_system.json
  15. 5
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  16. 9
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  17. 7
      application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
  18. 37
      application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java
  19. 47
      application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java
  20. 17
      application/src/main/java/org/thingsboard/server/controller/BaseController.java
  21. 11
      application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
  22. 26
      application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java
  23. 2
      application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java
  24. 46
      application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java
  25. 3
      application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java
  26. 1
      application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
  27. 24
      application/src/main/resources/thingsboard.yml
  28. 2
      application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java
  29. 4
      common/cluster-api/pom.xml
  30. 4
      common/dao-api/pom.xml
  31. 3
      common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java
  32. 30
      common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java
  33. 11
      common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java
  34. 120
      common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java
  35. 122
      common/script/script-api/src/test/java/org/thingsboard/script/api/AbstractScriptInvokeServiceTest.java
  36. 91
      common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java
  37. 88
      common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java
  38. 4
      dao/pom.xml
  39. 20
      dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java
  40. 98
      dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java
  41. 2
      monitoring/pom.xml
  42. 133
      monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java
  43. 12
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java
  44. 15
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java
  45. 14
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java
  46. 20
      netty-mqtt/pom.xml
  47. 24
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java
  48. 30
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java
  49. 2
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java
  50. 20
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java
  51. 90
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java
  52. 3
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java
  53. 134
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java
  54. 119
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java
  55. 85
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java
  56. 17
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java
  57. 4
      netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java
  58. 21
      netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategy.java
  59. 82
      netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java
  60. 101
      netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java
  61. 210
      netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java
  62. 63
      netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java
  63. 202
      netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java
  64. 95
      netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java
  65. 151
      netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java
  66. 84
      netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java
  67. 141
      netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java
  68. 3
      netty-mqtt/src/test/resources/junit-platform.properties
  69. 51
      pom.xml
  70. 26
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java
  71. 5
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
  72. 17
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java
  73. 8
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java
  74. 21
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java
  75. 76
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java
  76. 4
      tools/pom.xml
  77. 6
      tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java
  78. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html
  79. 4
      ui-ngx/src/app/modules/home/components/event/event-table-config.ts
  80. 8
      ui-ngx/src/app/modules/home/components/event/event-table.component.ts
  81. 3
      ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html
  82. 2
      ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html
  83. 3
      ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html
  84. 3
      ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html
  85. 2
      ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html
  86. 2
      ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html
  87. 2
      ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html
  88. 2
      ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html
  89. 2
      ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html
  90. 4
      ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html
  91. 4
      ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html
  92. 2
      ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html
  93. 2
      ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html
  94. 6
      ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.html
  95. 2
      ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html
  96. 9
      ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts
  97. 9
      ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts
  98. 10
      ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts
  99. 10
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts
  100. 168
      ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts

63
application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg

@ -3,11 +3,11 @@
"description": "Bottom right elbow connector",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `-${dashWidth + (dashGap || dashWidth)}` : `${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n",
"stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 200,100 L 125,100 Q 100,100 100,125 L 100, 200';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n",
"tags": [
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
}
],
@ -53,8 +53,8 @@
},
{
"id": "animationDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
@ -130,36 +130,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -169,7 +146,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -177,11 +154,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -190,8 +167,8 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7",
"disabled": false,
@ -199,13 +176,14 @@
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -214,13 +192,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -228,7 +207,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [
@ -250,5 +229,5 @@
}
]
}]]></tb:metadata>
<path d="M200 100H132C115 100 100 115 100 132V200" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><g tb:tag="animationGroup"/>
<path d="M 100,200 L 100,125 Q 100,100 125,100 L 200, 100" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><g tb:tag="animationGroup"/>
</svg>

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

69
application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg

@ -3,11 +3,11 @@
"description": "Bottom tee connector",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst rightLine = \"M100 100H200\";\nconst bottomLine = \"M 100,200 V 103\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}",
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst leftLineReversed = \"M 100,100 H 0\";\nconst rightLine = \"M100 100H200\";\nconst rightLineReversed = \"M 200,100 H 100\";\nconst bottomLine = \"M 100,200 V 103\";\nconst bottomLineReversed = \"M 100,103 V 200\";\n\nprepareFlowAnimation('left', leftLine, leftLineReversed);\nprepareFlowAnimation('right', rightLine, rightLineReversed);\nprepareFlowAnimation('bottom', bottomLine, bottomLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}\n",
"tags": [
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
},
{
@ -58,8 +58,8 @@
},
{
"id": "leftFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.left-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -174,8 +174,8 @@
},
{
"id": "rightFlowDirection",
"name": "{i18n:scada.symbol.flow-direction}",
"hint": "{i18n:scada.symbol.flow-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.right-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -290,8 +290,8 @@
},
{
"id": "bottomFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.bottom-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -367,36 +367,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -406,7 +383,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -414,11 +391,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -427,8 +404,8 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7",
"disabled": false,
@ -436,13 +413,14 @@
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -451,13 +429,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -465,7 +444,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

74
application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg

@ -3,11 +3,11 @@
"description": "Cross connector",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst topLine = \"M100 97L100 0\";\nconst rightLine = \"M100 100H200\";\nconst bottomLine = \"M 100,200 V 103\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}",
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst leftLineReversed = \"M 100,100 H 0\";\nconst topLine = \"M100 97L100 0\";\nconst topLineReversed = \"M 100,0 V 97\";\nconst rightLine = \"M100 100H200\";\nconst rightLineReversed = \"M 200,100 H 100\";\nconst bottomLine = \"M 100,200 V 103\";\nconst bottomLineReversed = \"M 100,103 V 200\";\n\nprepareFlowAnimation('left', leftLine, leftLineReversed);\nprepareFlowAnimation('top', topLine, topLineReversed);\nprepareFlowAnimation('right', rightLine, rightLineReversed);\nprepareFlowAnimation('bottom', bottomLine, bottomLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}",
"tags": [
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
},
{
@ -58,8 +58,8 @@
},
{
"id": "leftFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.left-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -174,8 +174,8 @@
},
{
"id": "topFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.top-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -290,8 +290,8 @@
},
{
"id": "rightFlowDirection",
"name": "{i18n:scada.symbol.flow-direction}",
"hint": "{i18n:scada.symbol.flow-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.right-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -406,8 +406,8 @@
},
{
"id": "bottomFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.bottom-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -483,14 +483,6 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
@ -500,29 +492,15 @@
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"condition": "return model.mainLine;",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
"disabled": false
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -530,11 +508,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -543,8 +521,8 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7",
"disabled": false,
@ -552,13 +530,14 @@
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -567,13 +546,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -581,7 +561,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 19 KiB

112
application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg

@ -3,7 +3,7 @@
"description": "Horizontal connector with an optional directional arrow to visually indicate flow.",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n",
"stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 200,100 H 0';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n\n",
"tags": [
{
"tag": "arrow",
@ -12,7 +12,7 @@
},
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
}
],
@ -53,18 +53,18 @@
"defaultWidgetActionSettings": null
},
{
"id": "arrowDirection",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"id": "flowAnimation",
"name": "{i18n:scada.symbol.flow-animation}",
"hint": "{i18n:scada.symbol.flow-animation-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": "{i18n:scada.symbol.forward}",
"falseLabel": "{i18n:scada.symbol.reverse}",
"stateLabel": "{i18n:scada.symbol.forward}",
"trueLabel": "{i18n:scada.symbol.present}",
"falseLabel": "{i18n:scada.symbol.absent}",
"stateLabel": "{i18n:scada.symbol.flow-present}",
"defaultGetValueSettings": {
"action": "DO_NOTHING",
"defaultValue": true,
"defaultValue": false,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
@ -72,34 +72,38 @@
"persistentPollingInterval": 1000
},
"getAttribute": {
"scope": null,
"key": "state"
"key": "state",
"scope": null
},
"getTimeSeries": {
"key": "state"
},
"getAlarmStatus": {
"severityList": null,
"typeList": null
},
"dataToValue": {
"type": "NONE",
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
}
},
"defaultSetValueSettings": null,
"defaultWidgetActionSettings": null
},
{
"id": "flowAnimation",
"name": "{i18n:scada.symbol.flow-animation}",
"hint": "{i18n:scada.symbol.flow-animation-hint}",
"id": "arrowDirection",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": "{i18n:scada.symbol.present}",
"falseLabel": "{i18n:scada.symbol.absent}",
"stateLabel": "{i18n:scada.symbol.flow-present}",
"trueLabel": "{i18n:scada.symbol.forward}",
"falseLabel": "{i18n:scada.symbol.reverse}",
"stateLabel": "{i18n:scada.symbol.forward}",
"defaultGetValueSettings": {
"action": "DO_NOTHING",
"defaultValue": false,
"defaultValue": true,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
@ -107,20 +111,16 @@
"persistentPollingInterval": 1000
},
"getAttribute": {
"key": "state",
"scope": null
"scope": null,
"key": "state"
},
"getTimeSeries": {
"key": "state"
},
"getAlarmStatus": {
"severityList": null,
"typeList": null
},
"dataToValue": {
"type": "NONE",
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
}
},
"defaultSetValueSettings": null,
@ -166,36 +166,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -205,7 +182,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -213,11 +190,10 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -226,20 +202,23 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7"
"default": "#C8DFF7",
"disabled": false,
"visible": true
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -248,13 +227,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -262,7 +242,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

75
application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg

@ -3,11 +3,11 @@
"description": "Left bottom elbow connector",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n",
"stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 100,200 L 100,125 Q 100,100 75,100 L 0, 100';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n",
"tags": [
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
}
],
@ -32,8 +32,8 @@
"persistentPollingInterval": 1000
},
"getAttribute": {
"key": "state",
"scope": null
"scope": "SHARED_SCOPE",
"key": "flow"
},
"getTimeSeries": {
"key": "state"
@ -44,8 +44,8 @@
},
"dataToValue": {
"type": "NONE",
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
}
},
"defaultSetValueSettings": null,
@ -53,8 +53,8 @@
},
{
"id": "animationDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
@ -130,36 +130,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -169,7 +146,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -177,11 +154,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -190,20 +167,23 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7"
"default": "#C8DFF7",
"disabled": false,
"visible": true
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -212,13 +192,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -226,7 +207,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [
@ -248,5 +229,5 @@
}
]
}]]></tb:metadata>
<path d="M100 200L100 131C100 113.879 86.1208 100 69 100L0 100" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><g tb:tag="animationGroup"/>
<path d="M 0,100 L 75,100 Q 100,100 100,125 L 100, 200" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><g tb:tag="animationGroup"/>
</svg>

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

71
application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg

@ -3,11 +3,11 @@
"description": "Left tee connector",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H97\";\nconst topLine = \"M100 100L100 0\";\nconst bottomLine = \"M 100,200 V 100\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}",
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H97\";\nconst leftLineReversed = \"M 97,100 H 0\";\nconst topLine = \"M100 100L100 0\";\nconst topLineReversed = \"M 100,0 V 100\";\nconst bottomLine = \"M 100,200 V 100\";\nconst bottomLineReversed = \"M 100,100 V 200\";\n\nprepareFlowAnimation('left', leftLine, leftLineReversed);\nprepareFlowAnimation('top', topLine, topLineReversed);\nprepareFlowAnimation('bottom', bottomLine, bottomLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}",
"tags": [
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
},
{
@ -58,8 +58,8 @@
},
{
"id": "leftFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.left-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -174,8 +174,8 @@
},
{
"id": "topFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.top-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -290,8 +290,8 @@
},
{
"id": "bottomFlowDirection",
"name": "{i18n:scada.symbol.flow-direction}",
"hint": "{i18n:scada.symbol.flow-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.bottom-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -367,36 +367,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -406,7 +383,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -414,11 +391,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -427,8 +404,8 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7",
"disabled": false,
@ -436,13 +413,14 @@
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -451,13 +429,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -465,7 +444,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [
@ -487,5 +466,5 @@
}
]
}]]></tb:metadata>
<path d="M100 0V200" stroke="#1A1A1A" stroke-width="6" id="path6" tb:tag="line"/><path d="M100 113V87C100 87 100 100 86 100C100 100 100 113 100 113Z" fill="#1A1A1A" stroke="#1A1A1A" stroke-width="2" id="path8" tb:tag="line-color"/><path d="M0 100H85C93.2843 100 100 93.2843 100 85V0" stroke="#1A1A1A" stroke-width="6" id="path4" tb:tag="line"/><path d="M0 100H85C93.2843 100 100 106.716 100 115V200" stroke="#1A1A1A" stroke-width="6" id="path2" tb:tag="line"/><g tb:tag="animationGroup"><g tb:tag="leftLine"/><g tb:tag="topLine"/><g tb:tag="bottomLine"/></g>
<path d="M100 0V200" stroke="#1A1A1A" stroke-width="6" id="path6" tb:tag="line"/><path d="M100 113V87C100 87 100 100 86 100C100 100 100 113 100 113Z" fill="#1A1A1A" stroke="#1A1A1A" stroke-width="2" id="path8" tb:tag="line-color"/><path d="M0 100H85C93.2843 100 100 93.2843 100 85V0" stroke="#1A1A1A" stroke-width="6" id="path4" tb:tag="line"/><path d="M0 100H85C93.2843 100 100 106.716 100 115V200" stroke="#1A1A1A" stroke-width="6" id="path2" tb:tag="line"/><g tb:tag="animationGroup"><g tb:tag="leftLine"/><g tb:tag="topLine"> </g><g tb:tag="bottomLine"> </g></g>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

63
application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg

@ -3,11 +3,11 @@
"description": "Left top elbow connector",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n",
"stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 100,0 L 100,75 Q 100,100 75,100 L 0, 100';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n",
"tags": [
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
}
],
@ -53,8 +53,8 @@
},
{
"id": "animationDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
@ -130,36 +130,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -169,7 +146,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -177,11 +154,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -190,8 +167,8 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7",
"disabled": false,
@ -199,13 +176,14 @@
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -214,13 +192,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -228,7 +207,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [
@ -250,5 +229,5 @@
}
]
}]]></tb:metadata>
<path d="M0 100H69C86.1208 100 100 86.1208 100 69V0" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><g tb:tag="animationGroup"/>
<path d="M 0,100 L 75,100 Q 100,100 100,75 L 100, 0" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><g tb:tag="animationGroup"/>
</svg>

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

118
application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg

@ -1,10 +1,9 @@
<svg width="400" height="200" fill="none" version="1.1" viewBox="0 0 400 200" xmlns="http://www.w3.org/2000/svg" xmlns:tb="https://thingsboard.io/svg">
<tb:metadata><![CDATA[{
<svg xmlns="http://www.w3.org/2000/svg" xmlns:tb="https://thingsboard.io/svg" width="400" height="200" fill="none" version="1.1" viewBox="0 0 400 200"><tb:metadata xmlns=""><![CDATA[{
"title": "HP Long horizontal connector",
"description": "Long horizontal connector with an optional directional arrow to visually indicate flow.",
"widgetSizeX": 2,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n",
"stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 400,100 H 0';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n",
"tags": [
{
"tag": "arrow",
@ -13,7 +12,7 @@
},
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
}
],
@ -54,18 +53,18 @@
"defaultWidgetActionSettings": null
},
{
"id": "arrowDirection",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"id": "flowAnimation",
"name": "{i18n:scada.symbol.flow-animation}",
"hint": "{i18n:scada.symbol.flow-animation-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": "{i18n:scada.symbol.forward}",
"falseLabel": "{i18n:scada.symbol.reverse}",
"stateLabel": "{i18n:scada.symbol.forward}",
"trueLabel": "{i18n:scada.symbol.present}",
"falseLabel": "{i18n:scada.symbol.absent}",
"stateLabel": "{i18n:scada.symbol.flow-present}",
"defaultGetValueSettings": {
"action": "DO_NOTHING",
"defaultValue": true,
"defaultValue": false,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
@ -73,34 +72,38 @@
"persistentPollingInterval": 1000
},
"getAttribute": {
"scope": null,
"key": "state"
"key": "state",
"scope": null
},
"getTimeSeries": {
"key": "state"
},
"getAlarmStatus": {
"severityList": null,
"typeList": null
},
"dataToValue": {
"type": "NONE",
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
}
},
"defaultSetValueSettings": null,
"defaultWidgetActionSettings": null
},
{
"id": "flowAnimation",
"name": "{i18n:scada.symbol.flow-animation}",
"hint": "{i18n:scada.symbol.flow-animation-hint}",
"id": "arrowDirection",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": "{i18n:scada.symbol.present}",
"falseLabel": "{i18n:scada.symbol.absent}",
"stateLabel": "{i18n:scada.symbol.flow-present}",
"trueLabel": "{i18n:scada.symbol.forward}",
"falseLabel": "{i18n:scada.symbol.reverse}",
"stateLabel": "{i18n:scada.symbol.forward}",
"defaultGetValueSettings": {
"action": "DO_NOTHING",
"defaultValue": false,
"defaultValue": true,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
@ -108,20 +111,16 @@
"persistentPollingInterval": 1000
},
"getAttribute": {
"key": "state",
"scope": null
"scope": null,
"key": "state"
},
"getTimeSeries": {
"key": "state"
},
"getAlarmStatus": {
"severityList": null,
"typeList": null
},
"dataToValue": {
"type": "NONE",
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
}
},
"defaultSetValueSettings": null,
@ -167,36 +166,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -206,7 +182,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -214,11 +190,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -227,20 +203,23 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7"
"default": "#C8DFF7",
"disabled": false,
"visible": true
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -249,13 +228,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -263,7 +243,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [
@ -285,5 +265,5 @@
}
]
}]]></tb:metadata>
<path d="m0 100h400" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><path d="m229 100-58 29v-58z" fill="#1a1a1a" tb:tag="arrow"/><g tb:tag="animationGroup"/>
<path d="M0 100H400" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><path d="m229 100-58 29v-58z" fill="#1a1a1a" tb:tag="arrow"/><g tb:tag="animationGroup"/>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

119
application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg

@ -3,7 +3,7 @@
"description": "Long vertical connector with an optional directional arrow to visually indicate flow.",
"widgetSizeX": 1,
"widgetSizeY": 2,
"stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n",
"stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 100,0 V 400';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n\n",
"tags": [
{
"tag": "arrow",
@ -12,7 +12,7 @@
},
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
}
],
@ -53,18 +53,18 @@
"defaultWidgetActionSettings": null
},
{
"id": "arrowDirection",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"id": "flowAnimation",
"name": "{i18n:scada.symbol.flow-animation}",
"hint": "{i18n:scada.symbol.flow-animation-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": "{i18n:scada.symbol.forward}",
"falseLabel": "{i18n:scada.symbol.reverse}",
"stateLabel": "{i18n:scada.symbol.forward}",
"trueLabel": "{i18n:scada.symbol.present}",
"falseLabel": "{i18n:scada.symbol.absent}",
"stateLabel": "{i18n:scada.symbol.flow-present}",
"defaultGetValueSettings": {
"action": "DO_NOTHING",
"defaultValue": true,
"defaultValue": false,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
@ -72,34 +72,38 @@
"persistentPollingInterval": 1000
},
"getAttribute": {
"scope": null,
"key": "state"
"key": "state",
"scope": null
},
"getTimeSeries": {
"key": "state"
},
"getAlarmStatus": {
"severityList": null,
"typeList": null
},
"dataToValue": {
"type": "NONE",
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
}
},
"defaultSetValueSettings": null,
"defaultWidgetActionSettings": null
},
{
"id": "flowAnimation",
"name": "{i18n:scada.symbol.flow-animation}",
"hint": "{i18n:scada.symbol.flow-animation-hint}",
"id": "arrowDirection",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": "{i18n:scada.symbol.present}",
"falseLabel": "{i18n:scada.symbol.absent}",
"stateLabel": "{i18n:scada.symbol.flow-present}",
"trueLabel": "{i18n:scada.symbol.forward}",
"falseLabel": "{i18n:scada.symbol.reverse}",
"stateLabel": "{i18n:scada.symbol.forward}",
"defaultGetValueSettings": {
"action": "DO_NOTHING",
"defaultValue": false,
"defaultValue": true,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
@ -107,20 +111,16 @@
"persistentPollingInterval": 1000
},
"getAttribute": {
"key": "state",
"scope": null
"scope": null,
"key": "state"
},
"getTimeSeries": {
"key": "state"
},
"getAlarmStatus": {
"severityList": null,
"typeList": null
},
"dataToValue": {
"type": "NONE",
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
}
},
"defaultSetValueSettings": null,
@ -166,36 +166,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -205,7 +182,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -213,11 +190,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -226,20 +203,23 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7"
"default": "#C8DFF7",
"disabled": false,
"visible": true
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -248,21 +228,20 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"step": 1,
"disabled": false,
"visible": true
"min": 0,
"step": 1
},
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [
@ -284,5 +263,5 @@
}
]
}]]></tb:metadata>
<path d="m100 400v-400" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><path d="m100 171 29 58h-58l29-58z" fill="#1A1A1A" tb:tag="arrow"/><g tb:tag="animationGroup"/>
<path d="M 100,400 V 0" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><path d="m100 171 29 58h-58l29-58z" fill="#1A1A1A" tb:tag="arrow"/><g tb:tag="animationGroup"/>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

69
application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg

@ -3,11 +3,11 @@
"description": "Right tee connector",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst topLine = \"M100 100L100 0\";\nconst rightLine = \"M103 100H200\";\nconst bottomLine = \"M 100,200 V 100\";\n\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}",
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst topLine = \"M100 100L100 0\";\nconst topLineReversed = \"M 100,0 V 100\";\nconst rightLine = \"M103 100H200\";\nconst rightLineReversed = \"M 200,100 H 103\";\nconst bottomLine = \"M 100,200 V 100\";\nconst bottomLineReversed = \"M 100,100 V 200\";\n\nprepareFlowAnimation('top', topLine, topLineReversed);\nprepareFlowAnimation('right', rightLine, rightLineReversed);\nprepareFlowAnimation('bottom', bottomLine, bottomLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}",
"tags": [
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
},
{
@ -58,8 +58,8 @@
},
{
"id": "topFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.top-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -174,8 +174,8 @@
},
{
"id": "rightFlowDirection",
"name": "{i18n:scada.symbol.flow-direction}",
"hint": "{i18n:scada.symbol.flow-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.right-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -290,8 +290,8 @@
},
{
"id": "bottomFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.bottom-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -367,36 +367,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -406,7 +383,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -414,11 +391,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -427,8 +404,8 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7",
"disabled": false,
@ -436,13 +413,14 @@
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -451,13 +429,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -465,7 +444,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

63
application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg

@ -3,11 +3,11 @@
"description": "Top right elbow connector",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `-${dashWidth + (dashGap || dashWidth)}` : `${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n",
"stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 200,100 L 125,100 Q 100,100 100,75 L 100, 0';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n",
"tags": [
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
}
],
@ -53,8 +53,8 @@
},
{
"id": "animationDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
@ -130,36 +130,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -169,7 +146,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -177,11 +154,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -190,8 +167,8 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7",
"disabled": false,
@ -199,13 +176,14 @@
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -214,13 +192,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -228,7 +207,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [
@ -250,5 +229,5 @@
}
]
}]]></tb:metadata>
<path d="M100 0V69C100 86.1208 113.879 100 131 100H200" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><g tb:tag="animationGroup"/>
<path d="M 100,0 L 100,75 Q 100,100 125,100 L 200, 100" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><g tb:tag="animationGroup"/>
</svg>

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

69
application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg

@ -3,11 +3,11 @@
"description": "Top tee connector",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst topLine = \"M100 97L100 0\";\nconst rightLine = \"M100 100H200\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}",
"stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst leftLineReversed = \"M 100,100 H 0\";\nconst topLine = \"M100 97L100 0\";\nconst topLineReversed = \"M 100,0 V 97\";\nconst rightLine = \"M100 100H200\";\nconst rightLineReversed = \"M 200,100 H 100\";\n\nprepareFlowAnimation('left', leftLine, leftLineReversed);\nprepareFlowAnimation('top', topLine, topLineReversed);\nprepareFlowAnimation('right', rightLine, rightLineReversed);\n\nfunction prepareFlowAnimation(prefix, line, reversedLine) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const duration = 1 / flowAnimationSpeed;\n \n let animateFlow = ctx.api.connectorAnimation(animation);\n \n if (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, reversedLine).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n } else {\n if (animateFlow) {\n animateFlow.finish();\n }\n }\n}",
"tags": [
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
},
{
@ -58,8 +58,8 @@
},
{
"id": "leftFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.left-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -174,8 +174,8 @@
},
{
"id": "topFlowDirection",
"name": "{i18n:scada.symbol.animation-direction}",
"hint": "{i18n:scada.symbol.animation-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.top-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -290,8 +290,8 @@
},
{
"id": "rightFlowDirection",
"name": "{i18n:scada.symbol.flow-direction}",
"hint": "{i18n:scada.symbol.flow-direction-hint}",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": "{i18n:scada.symbol.right-connector}",
"type": "value",
"valueType": "BOOLEAN",
@ -367,36 +367,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -406,7 +383,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -414,11 +391,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -427,8 +404,8 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7",
"disabled": false,
@ -436,13 +413,14 @@
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -451,13 +429,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -465,7 +444,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

115
application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg

@ -3,7 +3,7 @@
"description": "Vertical connector with an optional directional arrow to visually indicate flow.",
"widgetSizeX": 1,
"widgetSizeY": 1,
"stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(`<path style=\"stroke-dasharray: ${dashArray}; stroke-linecap: ${dashCap}; stroke-dashoffset: 0;\" d=\"${line}\" stroke-miterlimit=\"10\" fill=\"none\" stroke=\"${lineColor}\" stroke-width=\"${lineWidth}\"><animate attributeName=\"stroke-dashoffset\" values=\"${value};0\" dur=\"${duration}s\" begin=\"-${offset}ms\" calcMode=\"linear\" repeatCount=\"indefinite\" /></path>`);\n}\n",
"stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst lineReversed = 'M 100,0 V 200';\nconst animation = ctx.tags.animationGroup[0];\nconst duration = 1 / flowAnimationSpeed;\n\nlet animateFlow = ctx.api.connectorAnimation(animation);\n\nif (flowAnimation) {\n if (!animateFlow) {\n animateFlow = ctx.api.connectorAnimate(animation, line, lineReversed).flowAppearance(lineWidth, lineColor, dashCap, dashWidth, dashGap).duration(duration).direction(flowDirection).play();\n } else {\n animateFlow.duration(duration).direction(flowDirection).play();\n }\n} else {\n if (animateFlow) {\n animateFlow.finish();\n }\n}\n",
"tags": [
{
"tag": "arrow",
@ -12,7 +12,7 @@
},
{
"tag": "line",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nif (ctx.properties.mainLine) {\n element.attr({'stroke-width': ctx.properties.mainLineSize});\n} else {\n element.attr({'stroke-width': ctx.properties.secondaryLineSize});\n}",
"stateRenderFunction": "element.stroke(ctx.properties.lineColor);\nelement.attr({'stroke-width': ctx.properties.mainLineSize});",
"actions": null
}
],
@ -53,18 +53,18 @@
"defaultWidgetActionSettings": null
},
{
"id": "arrowDirection",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"id": "flowAnimation",
"name": "{i18n:scada.symbol.flow-animation}",
"hint": "{i18n:scada.symbol.flow-animation-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": "{i18n:scada.symbol.forward}",
"falseLabel": "{i18n:scada.symbol.reverse}",
"stateLabel": "{i18n:scada.symbol.forward}",
"trueLabel": "{i18n:scada.symbol.present}",
"falseLabel": "{i18n:scada.symbol.absent}",
"stateLabel": "{i18n:scada.symbol.flow-present}",
"defaultGetValueSettings": {
"action": "DO_NOTHING",
"defaultValue": true,
"defaultValue": false,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
@ -72,34 +72,38 @@
"persistentPollingInterval": 1000
},
"getAttribute": {
"scope": null,
"key": "state"
"key": "state",
"scope": null
},
"getTimeSeries": {
"key": "state"
},
"getAlarmStatus": {
"severityList": null,
"typeList": null
},
"dataToValue": {
"type": "NONE",
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
}
},
"defaultSetValueSettings": null,
"defaultWidgetActionSettings": null
},
{
"id": "flowAnimation",
"name": "{i18n:scada.symbol.flow-animation}",
"hint": "{i18n:scada.symbol.flow-animation-hint}",
"id": "arrowDirection",
"name": "{i18n:scada.symbol.arrow-direction}",
"hint": "{i18n:scada.symbol.arrow-direction-hint}",
"group": null,
"type": "value",
"valueType": "BOOLEAN",
"trueLabel": "{i18n:scada.symbol.present}",
"falseLabel": "{i18n:scada.symbol.absent}",
"stateLabel": "{i18n:scada.symbol.flow-present}",
"trueLabel": "{i18n:scada.symbol.forward}",
"falseLabel": "{i18n:scada.symbol.reverse}",
"stateLabel": "{i18n:scada.symbol.forward}",
"defaultGetValueSettings": {
"action": "DO_NOTHING",
"defaultValue": false,
"defaultValue": true,
"executeRpc": {
"method": "getState",
"requestTimeout": 5000,
@ -107,20 +111,16 @@
"persistentPollingInterval": 1000
},
"getAttribute": {
"key": "state",
"scope": null
"scope": null,
"key": "state"
},
"getTimeSeries": {
"key": "state"
},
"getAlarmStatus": {
"severityList": null,
"typeList": null
},
"dataToValue": {
"type": "NONE",
"compareToValue": true,
"dataToValueFunction": "/* Should return boolean value */\nreturn data;"
"dataToValueFunction": "/* Should return boolean value */\nreturn data;",
"compareToValue": true
}
},
"defaultSetValueSettings": null,
@ -166,36 +166,13 @@
}
],
"properties": [
{
"id": "mainLine",
"name": "{i18n:scada.symbol.main-line}",
"type": "switch",
"default": true,
"disabled": false,
"visible": true
},
{
"id": "mainLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 6,
"required": true,
"subLabel": "Main",
"divider": true,
"fieldSuffix": "px",
"min": 0,
"max": 99,
"step": 1,
"disabled": false,
"visible": true
},
{
"id": "secondaryLineSize",
"name": "{i18n:scada.symbol.line}",
"type": "number",
"default": 2,
"required": true,
"subLabel": "Secondary",
"divider": false,
"fieldSuffix": "px",
"min": 0,
"max": 99,
@ -205,7 +182,7 @@
},
{
"id": "lineColor",
"name": "{i18n:scada.symbol.line-color}",
"name": "{i18n:scada.symbol.line}",
"type": "color",
"default": "#1A1A1A",
"disabled": false,
@ -213,11 +190,11 @@
},
{
"id": "flowAnimationWidth",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 4,
"subLabel": "Width",
"divider": false,
"fieldSuffix": "px",
"min": 1,
"step": 1,
@ -226,20 +203,23 @@
},
{
"id": "flowAnimationColor",
"name": "{i18n:scada.symbol.flow}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line}",
"group": "{i18n:scada.symbol.flow}",
"type": "color",
"default": "#C8DFF7"
"default": "#C8DFF7",
"disabled": false,
"visible": true
},
{
"id": "flowStyleDash",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"required": true,
"subLabel": "{i18n:scada.symbol.dash}",
"divider": true,
"divider": false,
"fieldSuffix": "px",
"min": 0,
"step": 1,
@ -248,13 +228,14 @@
},
{
"id": "flowStyleGap",
"name": "{i18n:scada.symbol.flow-style}",
"group": "{i18n:scada.symbol.animation}",
"name": "{i18n:scada.symbol.flow-line-style}",
"hint": "{i18n:scada.symbol.flow-style-hint}",
"group": "{i18n:scada.symbol.flow}",
"type": "number",
"default": 10,
"subLabel": "{i18n:scada.symbol.gap}",
"fieldSuffix": "px",
"min": 1,
"min": 0,
"step": 1,
"disabled": false,
"visible": true
@ -262,7 +243,7 @@
{
"id": "flowDashCap",
"name": "{i18n:scada.symbol.flow-dash-cap}",
"group": "{i18n:scada.symbol.animation}",
"group": "{i18n:scada.symbol.flow}",
"type": "select",
"default": "butt",
"items": [
@ -284,5 +265,5 @@
}
]
}]]></tb:metadata>
<path d="M100 200L100 0" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><path d="M100 71L129 129H71L100 71Z" fill="#1A1A1A" tb:tag="arrow"/><g tb:tag="animationGroup"/>
<path d="M 100,200 V 0" stroke="#1A1A1A" stroke-width="6" tb:tag="line"/><path d="M100 71L129 129H71L100 71Z" fill="#1A1A1A" tb:tag="arrow"/><g tb:tag="animationGroup"/>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

2
application/src/main/data/json/system/widget_bundles/high_performance_scada_energy_system.json

@ -15,7 +15,7 @@
"hp_wind_turbine_cluster",
"hp_fuel_generator",
"hp_industrial_fuel_generator",
"hp_circuit_breaker2",
"hp_circuit_breaker",
"hp_horizontal_circuit_breaker",
"hp_voltage_relay",
"hp_3_phase_voltage_relay",

5
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -32,6 +32,7 @@ import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.rule.engine.api.notification.SlackService;
@ -639,6 +640,10 @@ public class ActorSystemContext {
@Getter
private long cfCalculationResultTimeout;
@Autowired
@Getter
private MqttClientSettings mqttClientSettings;
@Getter
@Setter
private TbActorSystem actorSystem;

9
application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java

@ -23,14 +23,15 @@ import org.bouncycastle.util.Arrays;
import org.thingsboard.common.util.DebugModeUtil;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ListeningExecutor;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAlarmService;
import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService;
import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache;
import org.thingsboard.rule.engine.api.RuleEngineCalculatedFieldQueueService;
import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.RuleEngineRpcService;
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService;
import org.thingsboard.rule.engine.api.ScriptEngine;
@ -1010,13 +1011,17 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getAuditLogService();
}
@Override
public MqttClientSettings getMqttClientSettings() {
return mainCtx.getMqttClientSettings();
}
private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) {
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("ruleNodeId", ruleNodeId.toString());
return metaData;
}
@Override
public void schedule(Runnable runnable, long delay, TimeUnit timeUnit) {
mainCtx.getScheduler().schedule(runnable, delay, timeUnit);

7
application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java

@ -47,6 +47,7 @@ import org.springframework.web.filter.ShallowEtagHeaderFilter;
import org.thingsboard.server.dao.oauth2.OAuth2Configuration;
import org.thingsboard.server.exception.ThingsboardErrorResponseHandler;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.AuthExceptionHandler;
import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.jwt.JwtTokenAuthenticationProcessingFilter;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider;
@ -129,6 +130,9 @@ public class ThingsboardSecurityConfiguration {
@Autowired
private RateLimitProcessingFilter rateLimitProcessingFilter;
@Autowired
private AuthExceptionHandler authExceptionHandler;
@Bean
protected PayloadSizeFilter payloadSizeFilter() {
return new PayloadSizeFilter(maxPayloadSizeConfig);
@ -235,7 +239,8 @@ public class ThingsboardSecurityConfiguration {
.addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(payloadSizeFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(rateLimitProcessingFilter, UsernamePasswordAuthenticationFilter.class);
.addFilterAfter(rateLimitProcessingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(authExceptionHandler, buildRestLoginProcessingFilter().getClass());
if (oauth2Configuration != null) {
http.oauth2Login(login -> login
.authorizationEndpoint(config -> config

37
application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java

@ -0,0 +1,37 @@
/**
* Copyright © 2016-2025 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.mqtt;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;
@Data
@Validated
@Configuration
@ConfigurationProperties(prefix = "mqtt.client.retransmission")
public class MqttClientRetransmissionSettingsComponent {
@PositiveOrZero
private int maxAttempts;
@PositiveOrZero
private long initialDelayMillis;
@PositiveOrZero
private double jitterFactor;
}

47
application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2025 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.mqtt;
import lombok.EqualsAndHashCode;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.springframework.context.annotation.Configuration;
import org.thingsboard.rule.engine.api.MqttClientSettings;
@ToString
@EqualsAndHashCode
@Configuration
@RequiredArgsConstructor
public class MqttClientSettingsComponent implements MqttClientSettings {
private final MqttClientRetransmissionSettingsComponent retransmissionSettingsComponent;
@Override
public int getRetransmissionMaxAttempts() {
return retransmissionSettingsComponent.getMaxAttempts();
}
@Override
public long getRetransmissionInitialDelayMillis() {
return retransmissionSettingsComponent.getInitialDelayMillis();
}
@Override
public double getRetransmissionJitterFactor() {
return retransmissionSettingsComponent.getJitterFactor();
}
}

17
application/src/main/java/org/thingsboard/server/controller/BaseController.java

@ -437,14 +437,7 @@ public abstract class BaseController {
} else if (exception instanceof AsyncRequestTimeoutException) {
return new ThingsboardException("Request timeout", ThingsboardErrorCode.GENERAL);
} else if (exception instanceof DataAccessException) {
if (!logControllerErrorStackTrace) { // not to log the error twice
log.warn("Database error: {} - {}", exception.getClass().getSimpleName(), ExceptionUtils.getRootCauseMessage(exception));
}
if (cause instanceof ConstraintViolationException) {
return new ThingsboardException(ExceptionUtils.getRootCause(exception).getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS);
} else {
return new ThingsboardException("Database error", ThingsboardErrorCode.GENERAL);
}
return new ThingsboardException(exception, ThingsboardErrorCode.DATABASE);
} else if (exception instanceof EntityVersionMismatchException) {
return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.VERSION_CONFLICT);
}
@ -964,8 +957,12 @@ public abstract class BaseController {
}
}
protected CalculatedField checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException {
return checkEntityId(calculatedFieldId, calculatedFieldService::findById, operation);
private void checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException {
validateId(calculatedFieldId, "Invalid entity id");
SecurityUser user = getCurrentUser();
CalculatedField cf = calculatedFieldService.findById(user.getTenantId(), calculatedFieldId);
checkNotNull(cf, calculatedFieldId.getEntityType().getNormalName() + " with id [" + calculatedFieldId + "] is not found");
checkEntityId(cf.getEntityId(), operation);
}
protected HomeDashboardInfo getHomeDashboardInfo(SecurityUser securityUser, JsonNode additionalInfo) {

11
application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java

@ -59,7 +59,6 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngi
import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import java.util.ArrayList;
import java.util.Collections;
@ -136,7 +135,6 @@ public class CalculatedFieldController extends BaseController {
public CalculatedField saveCalculatedField(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the calculated field.")
@RequestBody CalculatedField calculatedField) throws Exception {
calculatedField.setTenantId(getTenantId());
checkEntity(calculatedField.getId(), calculatedField, Resource.CALCULATED_FIELD);
checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD);
checkReferencedEntities(calculatedField.getConfiguration(), getCurrentUser());
return tbCalculatedFieldService.save(calculatedField, getCurrentUser());
@ -186,7 +184,7 @@ public class CalculatedFieldController extends BaseController {
public void deleteCalculatedField(@PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws Exception {
checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId);
CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId));
CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.DELETE);
CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser());
checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD);
tbCalculatedFieldService.delete(calculatedField, getCurrentUser());
}
@ -200,7 +198,7 @@ public class CalculatedFieldController extends BaseController {
public JsonNode getLatestCalculatedFieldDebugEvent(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException {
checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId);
CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId));
CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.READ);
CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser());
checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD);
TenantId tenantId = getCurrentUser().getTenantId();
return Optional.ofNullable(eventService.findLatestEvents(tenantId, calculatedFieldId, EventType.DEBUG_CALCULATED_FIELD, 1))
@ -272,7 +270,10 @@ public class CalculatedFieldController extends BaseController {
for (EntityId referencedEntityId : referencedEntityIds) {
EntityType entityType = referencedEntityId.getEntityType();
switch (entityType) {
case TENANT, CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ);
case TENANT -> {
return;
}
case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ);
default ->
throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities.");
}

26
application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java

@ -21,7 +21,9 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
@ -139,6 +141,8 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
ThingsboardException thingsboardException = (ThingsboardException) exception;
if (thingsboardException.getErrorCode() == ThingsboardErrorCode.SUBSCRIPTION_VIOLATION) {
handleSubscriptionException((ThingsboardException) exception, response);
} else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.DATABASE) {
handleDatabaseException(thingsboardException.getCause(), response);
} else {
handleThingsboardException((ThingsboardException) exception, response);
}
@ -148,8 +152,10 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
handleAccessDeniedException(response);
} else if (exception instanceof AuthenticationException) {
handleAuthenticationException((AuthenticationException) exception, response);
} else if (exception instanceof MaxPayloadSizeExceededException) {
} else if (exception instanceof MaxPayloadSizeExceededException) {
handleMaxPayloadSizeExceededException(response, (MaxPayloadSizeExceededException) exception);
} else if (exception instanceof DataAccessException e) {
handleDatabaseException(e, response);
} else {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(exception.getMessage(),
@ -201,6 +207,17 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
JacksonUtil.fromBytes(((HttpClientErrorException) subscriptionException.getCause()).getResponseBodyAsByteArray(), Object.class));
}
private void handleDatabaseException(Throwable databaseException, HttpServletResponse response) throws IOException {
ThingsboardErrorResponse errorResponse;
if (databaseException instanceof ConstraintViolationException) {
errorResponse = ThingsboardErrorResponse.of(ExceptionUtils.getRootCause(databaseException).getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST);
} else {
log.warn("Database error: {} - {}", databaseException.getClass().getSimpleName(), ExceptionUtils.getRootCauseMessage(databaseException));
errorResponse = ThingsboardErrorResponse.of("Database error", ThingsboardErrorCode.DATABASE, HttpStatus.INTERNAL_SERVER_ERROR);
}
writeResponse(errorResponse, response);
}
private void handleAccessDeniedException(HttpServletResponse response) throws IOException {
response.setStatus(HttpStatus.FORBIDDEN.value());
JacksonUtil.writeValue(response.getWriter(),
@ -233,4 +250,11 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand
}
}
// TODO: refactor this class to use this method instead of boilerplate JacksonUtil.writeValue(response.getWriter(), ...
private void writeResponse(ThingsboardErrorResponse errorResponse, HttpServletResponse response) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(errorResponse.getStatus());
JacksonUtil.writeValue(response.getWriter(), errorResponse);
}
}

2
application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java

@ -86,7 +86,7 @@ public class DefaultTbOtaPackageService extends AbstractTbEntityService implemen
otaPackage.setContentType(contentType);
otaPackage.setData(ByteBuffer.wrap(data));
otaPackage.setDataSize((long) data.length);
OtaPackageInfo savedOtaPackage = otaPackageService.saveOtaPackage(otaPackage);
OtaPackageInfo savedOtaPackage = new OtaPackageInfo(otaPackageService.saveOtaPackage(otaPackage));
logEntityActionService.logEntityAction(tenantId, savedOtaPackage.getId(), savedOtaPackage, null, actionType, user);
return savedOtaPackage;
} catch (Exception e) {

46
application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java

@ -0,0 +1,46 @@
/**
* Copyright © 2016-2025 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.security.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.thingsboard.server.exception.ThingsboardErrorResponseHandler;
@Component
@RequiredArgsConstructor
@Slf4j
public class AuthExceptionHandler extends OncePerRequestFilter {
private final ThingsboardErrorResponseHandler errorResponseHandler;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
try {
filterChain.doFilter(request, response);
} catch (AuthenticationException e) {
throw e;
} catch (Exception e) {
errorResponseHandler.handle(e, response);
}
}
}

3
application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java

@ -50,8 +50,7 @@ public enum Resource {
VERSION_CONTROL,
NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE,
EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE),
MOBILE_APP_SETTINGS,
CALCULATED_FIELD(EntityType.CALCULATED_FIELD);
MOBILE_APP_SETTINGS;
private final Set<EntityType> entityTypes;

1
application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java

@ -55,7 +55,6 @@ public class TenantAdminPermissions extends AbstractPermissions {
put(Resource.OAUTH2_CONFIGURATION_TEMPLATE, new PermissionChecker.GenericPermissionChecker(Operation.READ));
put(Resource.MOBILE_APP, tenantEntityPermissionChecker);
put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker);
put(Resource.CALCULATED_FIELD, tenantEntityPermissionChecker);
}
public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() {

24
application/src/main/resources/thingsboard.yml

@ -1952,3 +1952,27 @@ mobileApp:
googlePlayLink: "${TB_MOBILE_APP_GOOGLE_PLAY_LINK:https://play.google.com/store/apps/details?id=org.thingsboard.demo.app}"
# Link to App Store for Thingsboard Live mobile application
appStoreLink: "${TB_MOBILE_APP_APP_STORE_LINK:https://apps.apple.com/us/app/thingsboard-live/id1594355695}"
mqtt:
# MQTT client configuration parameters
client:
# Parameters that control the retransmission mechanism.
# This mechanism only applies to the handling of MQTT Publish, Subscribe, Unsubscribe and Pubrel messages.
# With the updated default settings:
# - After sending the message, wait approximately 5000 ms (± jitter) for the 1st attempt.
# - The 2nd attempt will occur after roughly 5000 * 2 = 10,000 ms (± jitter).
# - The 3rd attempt will occur after roughly 5000 * 4 = 20,000 ms (± jitter).
# - The 4th "attempt" will not actually perform a retransmission.
# Instead, the system will detect that the maximum number of attempts has been reached and drop the pending message.
retransmission:
# Maximum number of retransmission attempts allowed.
# If the attempt count exceeds this value, retransmissions will stop and the pending message will be dropped.
max_attempts: "${TB_MQTT_CLIENT_RETRANSMISSION_MAX_ATTEMPTS:3}"
# Base delay (in milliseconds) before the first retransmission attempt, measured from the moment the message is sent.
# Subsequent delays are calculated using exponential backoff.
# This base delay is also used as the reference value for applying jitter.
initial_delay_millis: "${TB_MQTT_CLIENT_RETRANSMISSION_INITIAL_DELAY_MILLIS:5000}"
# Jitter factor applied to the calculated retransmission delay.
# The actual delay is randomized within a range defined by multiplying the base delay by a factor between (1 - jitter_factor) and (1 + jitter_factor).
# For example, a jitter_factor of 0.15 means the actual delay may vary by up to ±15% of the base delay.
jitter_factor: "${TB_MQTT_CLIENT_RETRANSMISSION_JITTER_FACTOR:0.15}"

2
application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java

@ -216,7 +216,7 @@ public class OtaPackageControllerTest extends AbstractControllerTest {
Assert.assertEquals(CHECKSUM_ALGORITHM, savedFirmware.getChecksumAlgorithm().name());
Assert.assertEquals(CHECKSUM, savedFirmware.getChecksum());
testNotifyEntityAllOneTime(savedFirmware, savedFirmware.getId(), savedFirmware.getId(),
testNotifyEntityAllOneTime(new OtaPackageInfo(savedFirmware), savedFirmware.getId(), savedFirmware.getId(),
savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
ActionType.UPDATED);
}

4
common/cluster-api/pom.xml

@ -60,10 +60,6 @@
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
</dependency>
<dependency>
<groupId>com.github.java-json-tools</groupId>
<artifactId>json-schema-validator</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>

4
common/dao-api/pom.xml

@ -56,10 +56,6 @@
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
</dependency>
<dependency>
<groupId>com.github.java-json-tools</groupId>
<artifactId>json-schema-validator</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>

3
common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java

@ -31,7 +31,8 @@ public enum ThingsboardErrorCode {
TOO_MANY_UPDATES(34),
VERSION_CONFLICT(35),
SUBSCRIPTION_VIOLATION(40),
PASSWORD_VIOLATION(45);
PASSWORD_VIOLATION(45),
DATABASE(46);
private int errorCode;

30
common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java

@ -134,19 +134,29 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService
}
}
@Override
public ListenableFuture<UUID> eval(TenantId tenantId, ScriptType scriptType, String scriptBody, String... argNames) {
public String validate(TenantId tenantId, String scriptBody) {
if (isExecEnabled(tenantId)) {
if (scriptBodySizeExceeded(scriptBody)) {
return error(format("Script body exceeds maximum allowed size of %s symbols", getMaxScriptBodySize()));
return format("Script body exceeds maximum allowed size of %s symbols", getMaxScriptBodySize());
}
UUID scriptId = UUID.randomUUID();
requestsCounter.increment();
return withTimeoutAndStatsCallback(scriptId, null,
doEvalScript(tenantId, scriptType, scriptBody, scriptId, argNames), evalCallback, getMaxEvalRequestsTimeout());
} else {
return error("Script Execution is disabled due to API limits!");
return "Script Execution is disabled due to API limits!";
}
return null;
}
@Override
public ListenableFuture<UUID> eval(TenantId tenantId, ScriptType scriptType, String scriptBody, String... argNames) {
String validationError = validate(tenantId, scriptBody);
if (validationError != null) {
return error(validationError);
}
UUID scriptId = UUID.randomUUID();
requestsCounter.increment();
return withTimeoutAndStatsCallback(scriptId, null,
doEvalScript(tenantId, scriptType, scriptBody, scriptId, argNames), evalCallback, getMaxEvalRequestsTimeout());
}
@Override
@ -269,7 +279,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService
}
}
private boolean scriptBodySizeExceeded(String scriptBody) {
public boolean scriptBodySizeExceeded(String scriptBody) {
if (getMaxScriptBodySize() <= 0) return false;
return scriptBody.length() > getMaxScriptBodySize();
}
@ -297,7 +307,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService
return result != null && result.length() > getMaxResultSize();
}
private <T> ListenableFuture<T> error(String message) {
public <T> ListenableFuture<T> error(String message) {
return Futures.immediateFailedFuture(new RuntimeException(message));
}

11
common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java

@ -35,6 +35,8 @@ import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static java.lang.String.format;
/**
* Created by ashvayka on 26.09.18.
*/
@ -93,6 +95,15 @@ public abstract class AbstractJsInvokeService extends AbstractScriptInvokeServic
doRelease(scriptId, scriptInfoMap.remove(scriptId));
}
@Override
public String validate(TenantId tenantId, String scriptBody) {
String errorMessage = super.validate(tenantId, scriptBody);
if (errorMessage == null) {
return JsValidator.validate(scriptBody);
}
return errorMessage;
}
protected abstract ListenableFuture<UUID> doEval(UUID scriptId, JsScriptInfo jsInfo, String scriptBody);
protected abstract ListenableFuture<Object> doInvokeFunction(UUID scriptId, JsScriptInfo jsInfo, Object[] args);

120
common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsValidator.java

@ -0,0 +1,120 @@
/**
* Copyright © 2016-2025 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.script.api.js;
import java.util.regex.Pattern;
public class JsValidator {
static final Pattern ASYNC_PATTERN = Pattern.compile("\\basync\\b");
static final Pattern AWAIT_PATTERN = Pattern.compile("\\bawait\\b");
static final Pattern PROMISE_PATTERN = Pattern.compile("\\bPromise\\b");
static final Pattern SET_TIMEOUT_PATTERN = Pattern.compile("\\bsetTimeout\\b");
public static String validate(String scriptBody) {
if (scriptBody == null || scriptBody.trim().isEmpty()) {
return "Script body is empty";
}
//Quick check
if (!ASYNC_PATTERN.matcher(scriptBody).find()
&& !AWAIT_PATTERN.matcher(scriptBody).find()
&& !PROMISE_PATTERN.matcher(scriptBody).find()
&& !SET_TIMEOUT_PATTERN.matcher(scriptBody).find()) {
return null;
}
//Recheck if quick check failed. Ignoring comments and strings
String[] lines = scriptBody.split("\\r?\\n");
boolean insideMultilineComment = false;
for (String line : lines) {
String stripped = line;
// Handle multiline comments
if (insideMultilineComment) {
if (line.contains("*/")) {
insideMultilineComment = false;
stripped = line.substring(line.indexOf("*/") + 2); // continue after comment
} else {
continue; // skip line inside multiline comment
}
}
// Check for start of multiline comment
if (stripped.contains("/*")) {
int start = stripped.indexOf("/*");
int end = stripped.indexOf("*/", start + 2);
if (end != -1) {
// Inline multiline comment
stripped = stripped.substring(0, start) + stripped.substring(end + 2);
} else {
// Starts a block comment, continues on next lines
insideMultilineComment = true;
stripped = stripped.substring(0, start);
}
}
stripped = stripInlineComment(stripped);
stripped = stripStringLiterals(stripped);
if (ASYNC_PATTERN.matcher(stripped).find()) {
return "Script must not contain 'async' keyword.";
}
if (AWAIT_PATTERN.matcher(stripped).find()) {
return "Script must not contain 'await' keyword.";
}
if (PROMISE_PATTERN.matcher(stripped).find()) {
return "Script must not use 'Promise'.";
}
if (SET_TIMEOUT_PATTERN.matcher(stripped).find()) {
return "Script must not use 'setTimeout' method.";
}
}
return null;
}
private static String stripInlineComment(String line) {
int index = line.indexOf("//");
return index >= 0 ? line.substring(0, index) : line;
}
private static String stripStringLiterals(String line) {
StringBuilder sb = new StringBuilder();
boolean inSingleQuote = false;
boolean inDoubleQuote = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (c == '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
} else if (c == '\'' && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
}
if (!inSingleQuote && !inDoubleQuote) {
sb.append(c);
}
}
return sb.toString();
}
}

122
common/script/script-api/src/test/java/org/thingsboard/script/api/AbstractScriptInvokeServiceTest.java

@ -0,0 +1,122 @@
/**
* Copyright © 2016-2025 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.script.api;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.stats.StatsCounter;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
class AbstractScriptInvokeServiceTest {
AbstractScriptInvokeService service;
final UUID id = UUID.randomUUID();
final String scriptBody = "return true;";
final TenantId tenantId = TenantId.fromUUID(UUID.fromString("2ed9a658-45a5-4812-b212-9931f5749f30"));
@BeforeEach
void setUp() {
service = mock(AbstractScriptInvokeService.class, Mockito.RETURNS_DEEP_STUBS);
// Make sure core checks always pass
doReturn(true).when(service).isExecEnabled(any());
doReturn(50000L).when(service).getMaxScriptBodySize();
// Use real implementations
doCallRealMethod().when(service).scriptBodySizeExceeded(anyString());
doCallRealMethod().when(service).eval(any(), any(), any(), any(String[].class));
doCallRealMethod().when(service).error(anyString());
doCallRealMethod().when(service).validate(any(), anyString());
}
@Test
void evalWithValidationCallTest() throws ExecutionException, InterruptedException, TimeoutException {
ReflectionTestUtils.setField(service, "requestsCounter", mock(StatsCounter.class));
ReflectionTestUtils.setField(service, "evalCallback", mock(FutureCallback.class));
doReturn(Futures.immediateFuture(id)).when(service).doEvalScript(any(), any(), anyString(), any(), any(String[].class));
var future = service.eval(tenantId, ScriptType.RULE_NODE_SCRIPT, scriptBody, "x", "y");
assertThat(future.get(30, TimeUnit.SECONDS)).isEqualTo(id);
verify(service).validate(any(), anyString());
verify(service).validate(tenantId, scriptBody);
verify(service, never()).error(anyString());
}
@Test
void evalWithValidationCallErrorTest() throws ExecutionException, InterruptedException, TimeoutException {
doReturn(false).when(service).isExecEnabled(any());
var future = service.eval(tenantId, ScriptType.RULE_NODE_SCRIPT, scriptBody, "x", "y");
ExecutionException ex = assertThrows(ExecutionException.class, future::get);
assertThat(ex.getCause().getMessage()).isEqualTo("Script Execution is disabled due to API limits!");
assertThat(ex.getCause()).isInstanceOf(RuntimeException.class);
verify(service).validate(any(), anyString());
verify(service).validate(tenantId, scriptBody);
verify(service).error(anyString());
}
@Test
void validateScriptBodyTestExecEnabledTest() {
assertNull(service.validate(tenantId, scriptBody));
verify(service).isExecEnabled(tenantId);
}
@Test
void validateScriptBodyTestExecDisabledTest() {
doReturn(false).when(service).isExecEnabled(tenantId);
assertThat(service.validate(tenantId, scriptBody)).isEqualTo("Script Execution is disabled due to API limits!");
verify(service).isExecEnabled(tenantId);
}
@Test
void validateScriptBodySizeOKTest() {
assertNull(service.validate(tenantId, scriptBody));
verify(service).isExecEnabled(tenantId);
verify(service).scriptBodySizeExceeded(scriptBody);
}
@Test
void validateScriptBodySizeExceededTest() {
doReturn(10L).when(service).getMaxScriptBodySize();
assertThat(service.validate(tenantId, scriptBody)).isEqualTo("Script body exceeds maximum allowed size of 10 symbols");
verify(service).isExecEnabled(tenantId);
verify(service).scriptBodySizeExceeded(scriptBody);
}
}

91
common/script/script-api/src/test/java/org/thingsboard/script/api/js/AbstractJsInvokeServiceTest.java

@ -0,0 +1,91 @@
/**
* Copyright © 2016-2025 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.script.api.js;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import org.thingsboard.server.common.stats.StatsCounter;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.script.api.ScriptType;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@Slf4j
class AbstractJsInvokeServiceTest {
AbstractJsInvokeService service;
final UUID id = UUID.randomUUID();
@BeforeEach
void setUp() {
service = mock(AbstractJsInvokeService.class, Mockito.RETURNS_DEEP_STUBS);
ReflectionTestUtils.setField(service, "requestsCounter", mock(StatsCounter.class));
ReflectionTestUtils.setField(service, "evalCallback", mock(FutureCallback.class));
// Make sure core checks always pass
doReturn(true).when(service).isExecEnabled(any());
doReturn(false).when(service).scriptBodySizeExceeded(anyString());
doReturn(Futures.immediateFuture(id)).when(service).doEvalScript(any(), any(), anyString(), any(), any(String[].class));
// Use real implementations
doCallRealMethod().when(service).eval(any(), any(), any(), any(String[].class));
doCallRealMethod().when(service).error(anyString());
doCallRealMethod().when(service).validate(any(), anyString());
}
@Test
void shouldReturnValidationErrorFromJsValidator() throws ExecutionException, InterruptedException {
String scriptWithAsync = "async function test() {}";
var future = service.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, scriptWithAsync, "a", "b");
ExecutionException ex = assertThrows(ExecutionException.class, future::get);
assertTrue(ex.getCause().getMessage().contains("Script must not contain 'async' keyword."));
assertThat(ex.getCause()).isInstanceOf(RuntimeException.class);
verify(service).isExecEnabled(any());
verify(service).scriptBodySizeExceeded(any());
}
@Test
void shouldPassValidationAndCallSuperEval() throws ExecutionException, InterruptedException, TimeoutException {
String validScript = "function test() { return 42; }";
var result = service.eval(TenantId.SYS_TENANT_ID, ScriptType.RULE_NODE_SCRIPT, validScript, "x", "y");
assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(id);
verify(service, times(1)).isExecEnabled(any());
verify(service, times(1)).scriptBodySizeExceeded(any());
}
}

88
common/script/script-api/src/test/java/org/thingsboard/script/api/js/JsValidatorTest.java

@ -0,0 +1,88 @@
/**
* Copyright © 2016-2025 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.script.api.js;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
class JsValidatorTest {
@ParameterizedTest(name = "should return error for script \"{0}\"")
@ValueSource(strings = {
"async function test() {}",
"const result = await someFunc();",
"const result =\nawait\tsomeFunc();",
"setTimeout(1000);",
"new Promise((resolve) => {});",
"function test() { return 42; } \n\t await test()",
"""
function init() {
await doSomething();
}
""",
})
void shouldReturnErrorForInvalidScripts(String script) {
assertNotNull(JsValidator.validate(script));
}
@ParameterizedTest(name = "should pass validation for script: \"{0}\"")
@ValueSource(strings = {
"function test() { return 42; }",
"const result = 10 * 2;",
"// async is a keyword but not used: 'const word = \"async\";'",
"let note = 'setTimeout tight';",
"const word = \"async\";",
"const word = \"setTimeout\";",
"const word = \"Promise\";",
"const word = \"await\";",
"const word = 'async';",
"const word = 'setTimeout';",
"const word = 'Promise';",
"const word = 'await';",
"//function test() { return 42; }",
"// const result = 10 * 2;",
"// async is a keyword but not used: 'const word = \"async\";'",
"//setTimeout(1);",
"a=b+c; // await for a day",
"return new // Promise((resolve) => {",
"hello(); // async is a keyword but not used: 'const word = \"async\";'",
"setGoal(a); //setTimeout(1);",
" /* new Promise((resolve) => {}); // */ return 'await';",
" /* async */ function calc() {",
"/* async function abc() { \n await new Promise ( \t setTimeout () ) \n } \n*/",
})
void shouldReturnNullForValidScripts(String script) {
assertNull(JsValidator.validate(script));
}
@ParameterizedTest(name = "should return 'Script body is empty' for input: \"{0}\"")
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void shouldReturnErrorForEmptyOrNullScripts(String script) {
assertEquals("Script body is empty", JsValidator.validate(script));
}
}

4
dao/pom.xml

@ -59,6 +59,10 @@
<groupId>org.thingsboard.common</groupId>
<artifactId>util</artifactId>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>

20
dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java

@ -16,10 +16,10 @@
package org.thingsboard.server.dao.component;
import com.fasterxml.jackson.databind.JsonNode;
import com.github.fge.jsonschema.core.exceptions.ProcessingException;
import com.github.fge.jsonschema.core.report.ProcessingReport;
import com.github.fge.jsonschema.main.JsonSchemaFactory;
import com.github.fge.jsonschema.main.JsonValidator;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@ -36,6 +36,7 @@ import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.Validator;
import java.util.Optional;
import java.util.Set;
/**
* @author Andrew Shvayka
@ -89,15 +90,18 @@ public class BaseComponentDescriptorService implements ComponentDescriptorServic
@Override
public boolean validate(TenantId tenantId, ComponentDescriptor component, JsonNode configuration) {
JsonValidator validator = JsonSchemaFactory.byDefault().getValidator();
try {
if (!component.getConfigurationDescriptor().has("schema")) {
throw new DataValidationException("Configuration descriptor doesn't contain schema property!");
}
JsonNode configurationSchema = component.getConfigurationDescriptor().get("schema");
ProcessingReport report = validator.validate(configurationSchema, configuration);
return report.isSuccess();
} catch (ProcessingException e) {
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4);
JsonSchema schema = factory.getSchema(configurationSchema);
Set<ValidationMessage> validationMessages = schema.validate(configuration);
return validationMessages.isEmpty();
} catch (Exception e) {
throw new IncorrectParameterException(e.getMessage(), e);
}
}

98
dao/src/test/java/org/thingsboard/server/dao/component/BaseComponentDescriptorServiceTest.java

@ -0,0 +1,98 @@
/**
* Copyright © 2016-2025 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.component;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentClusteringMode;
import org.thingsboard.server.common.data.plugin.ComponentDescriptor;
import org.thingsboard.server.common.data.plugin.ComponentScope;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.dao.exception.IncorrectParameterException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BaseComponentDescriptorServiceTest {
private BaseComponentDescriptorService service;
private ComponentDescriptor componentDescriptor;
private TenantId tenantId;
@BeforeEach
void setUp() {
service = Mockito.spy(BaseComponentDescriptorService.class);
tenantId = TenantId.SYS_TENANT_ID;
// Create a simple component descriptor
componentDescriptor = new ComponentDescriptor();
componentDescriptor.setType(ComponentType.ACTION);
componentDescriptor.setScope(ComponentScope.TENANT);
componentDescriptor.setClusteringMode(ComponentClusteringMode.ENABLED);
componentDescriptor.setName("Test Component");
componentDescriptor.setClazz("org.thingsboard.test.TestComponent");
// Create configuration descriptor with schema from JSON string
String configDescriptorJson = """
{
"schema": {
"type": "object",
"properties": {
"testField": {
"type": "string"
}
},
"required": ["testField"]
}
}""";
componentDescriptor.setConfigurationDescriptor(JacksonUtil.toJsonNode(configDescriptorJson));
}
@Test
void testValidate() {
// Create valid configuration from JSON string
String validConfigJson = "{\"testField\": \"test value\"}";
JsonNode validConfig = JacksonUtil.toJsonNode(validConfigJson);
// Create invalid configuration (missing required field) from JSON string
String invalidConfigJson = "{}";
JsonNode invalidConfig = JacksonUtil.toJsonNode(invalidConfigJson);
// Test valid configuration
boolean validResult = service.validate(tenantId, componentDescriptor, validConfig);
assertTrue(validResult, "Valid configuration should pass validation");
// Test invalid configuration
boolean invalidResult = service.validate(tenantId, componentDescriptor, invalidConfig);
assertFalse(invalidResult, "Invalid configuration should fail validation");
// Test with component descriptor without schema
ComponentDescriptor noSchemaDescriptor = new ComponentDescriptor(componentDescriptor);
noSchemaDescriptor.setConfigurationDescriptor(JacksonUtil.toJsonNode("{}"));
// Should throw exception when schema is missing
assertThrows(IncorrectParameterException.class, () -> {
service.validate(tenantId, noSchemaDescriptor, validConfig);
}, "Should throw exception when schema is missing");
}
}

2
monitoring/pom.xml

@ -42,8 +42,6 @@
<pkg.implementationTitle>ThingsBoard Monitoring Service</pkg.implementationTitle>
<pkg.mainClass>org.thingsboard.monitoring.ThingsboardMonitoringApplication</pkg.mainClass>
<californium.version>2.6.1</californium.version>
<leshan.version>2.0.0-M4</leshan.version>
</properties>
<dependencies>

133
monitoring/src/main/java/org/thingsboard/monitoring/client/Lwm2mClient.java

@ -20,13 +20,16 @@ import lombok.Setter;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.californium.core.network.CoapEndpoint;
import org.eclipse.californium.core.network.config.NetworkConfig;
import org.eclipse.californium.core.observe.ObservationStore;
import org.eclipse.californium.scandium.DTLSConnector;
import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
import org.eclipse.leshan.client.californium.LeshanClient;
import org.eclipse.leshan.client.californium.LeshanClientBuilder;
import org.eclipse.californium.core.config.CoapConfig;
import org.eclipse.californium.elements.config.Configuration;
import org.eclipse.californium.scandium.config.DtlsConfig;
import org.eclipse.leshan.client.LeshanClient;
import org.eclipse.leshan.client.LeshanClientBuilder;
import org.eclipse.leshan.client.californium.endpoint.CaliforniumClientEndpointsProvider;
import org.eclipse.leshan.client.californium.endpoint.ClientProtocolProvider;
import org.eclipse.leshan.client.californium.endpoint.coap.CoapOscoreProtocolProvider;
import org.eclipse.leshan.client.californium.endpoint.coaps.CoapsClientProtocolProvider;
import org.eclipse.leshan.client.endpoint.LwM2mClientEndpointsProvider;
import org.eclipse.leshan.client.engine.DefaultRegistrationEngineFactory;
import org.eclipse.leshan.client.object.Security;
import org.eclipse.leshan.client.object.Server;
@ -34,9 +37,8 @@ import org.eclipse.leshan.client.observer.LwM2mClientObserver;
import org.eclipse.leshan.client.resource.BaseInstanceEnabler;
import org.eclipse.leshan.client.resource.DummyInstanceEnabler;
import org.eclipse.leshan.client.resource.ObjectsInitializer;
import org.eclipse.leshan.client.servers.ServerIdentity;
import org.eclipse.leshan.client.servers.LwM2mServer;
import org.eclipse.leshan.core.ResponseCode;
import org.eclipse.leshan.core.californium.EndpointFactory;
import org.eclipse.leshan.core.model.InvalidDDFFileException;
import org.eclipse.leshan.core.model.LwM2mModel;
import org.eclipse.leshan.core.model.ObjectLoader;
@ -53,7 +55,6 @@ import org.thingsboard.monitoring.util.ResourceUtils;
import javax.security.auth.Destroyable;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -95,9 +96,11 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable {
}
Security security = noSec(serverUri, 123);
NetworkConfig coapConfig = new NetworkConfig().setString(NetworkConfig.Keys.COAP_PORT, StringUtils.substringAfterLast(serverUri, ":"));
LeshanClient leshanClient;
Configuration coapConfig = new Configuration();
String portStr = StringUtils.substringAfterLast(serverUri, ":");
if (StringUtils.isNotEmpty(portStr)) {
coapConfig.set(CoapConfig.COAP_PORT, Integer.parseInt(portStr));
}
LwM2mModel model = new StaticModel(models);
ObjectsInitializer initializer = new ObjectsInitializer(model);
@ -105,118 +108,121 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable {
initializer.setInstancesForObject(SERVER, new Server(123, TimeUnit.MINUTES.toSeconds(5)));
initializer.setInstancesForObject(DEVICE, this);
initializer.setClassForObject(ACCESS_CONTROL, DummyInstanceEnabler.class);
DtlsConnectorConfig.Builder dtlsConfig = new DtlsConnectorConfig.Builder();
dtlsConfig.setRecommendedCipherSuitesOnly(true);
dtlsConfig.setClientOnly();
DefaultRegistrationEngineFactory engineFactory = new DefaultRegistrationEngineFactory();
engineFactory.setReconnectOnUpdate(false);
engineFactory.setResumeOnConnect(true);
// Create client endpoints Provider
List<ClientProtocolProvider> protocolProvider = new ArrayList<>();
protocolProvider.add(new CoapOscoreProtocolProvider());
protocolProvider.add(new CoapsClientProtocolProvider());
CaliforniumClientEndpointsProvider.Builder endpointsBuilder = new CaliforniumClientEndpointsProvider.Builder(
protocolProvider.toArray(new ClientProtocolProvider[protocolProvider.size()]));
EndpointFactory endpointFactory = new EndpointFactory() {
// Create Californium Configuration
Configuration clientCoapConfig = endpointsBuilder.createDefaultConfiguration();
@Override
public CoapEndpoint createUnsecuredEndpoint(InetSocketAddress address, NetworkConfig coapConfig,
ObservationStore store) {
CoapEndpoint.Builder builder = new CoapEndpoint.Builder();
builder.setInetSocketAddress(address);
builder.setNetworkConfig(coapConfig);
return builder.build();
}
// Set some DTLS stuff
clientCoapConfig.setTransient(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY);
clientCoapConfig.set(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY, true);
@Override
public CoapEndpoint createSecuredEndpoint(DtlsConnectorConfig dtlsConfig, NetworkConfig coapConfig,
ObservationStore store) {
CoapEndpoint.Builder builder = new CoapEndpoint.Builder();
DtlsConnectorConfig.Builder dtlsConfigBuilder = new DtlsConnectorConfig.Builder(dtlsConfig);
builder.setConnector(new DTLSConnector(dtlsConfigBuilder.build()));
builder.setNetworkConfig(coapConfig);
return builder.build();
}
};
// Set Californium Configuration
endpointsBuilder.setConfiguration(clientCoapConfig);
// creates EndpointsProvider
List<LwM2mClientEndpointsProvider> endpointsProvider = new ArrayList<>();
endpointsProvider.add(endpointsBuilder.build());
// Configure registration engine
DefaultRegistrationEngineFactory engineFactory = new DefaultRegistrationEngineFactory();
engineFactory.setReconnectOnUpdate(false);
engineFactory.setResumeOnConnect(true);
// Build the client
LeshanClientBuilder builder = new LeshanClientBuilder(endpoint);
builder.setObjects(initializer.createAll());
builder.setCoapConfig(coapConfig);
builder.setDtlsConfig(dtlsConfig);
builder.setEndpointsProviders(endpointsProvider.toArray(new LwM2mClientEndpointsProvider[endpointsProvider.size()]));
builder.setRegistrationEngineFactory(engineFactory);
builder.setEndpointFactory(endpointFactory);
builder.setDecoder(new DefaultLwM2mDecoder(false));
builder.setEncoder(new DefaultLwM2mEncoder(false));
leshanClient = builder.build();
// Add observer
LwM2mClientObserver observer = new LwM2mClientObserver() {
@Override
public void onBootstrapStarted(ServerIdentity bsserver, BootstrapRequest request) {}
public void onBootstrapStarted(LwM2mServer bsserver, BootstrapRequest request) {
// No implementation needed
}
@Override
public void onBootstrapSuccess(ServerIdentity bsserver, BootstrapRequest request) {}
public void onBootstrapSuccess(LwM2mServer bsserver, BootstrapRequest request) {
// No implementation needed
}
@Override
public void onBootstrapFailure(ServerIdentity bsserver, BootstrapRequest request,
ResponseCode responseCode, String errorMessage, Exception cause) {}
public void onBootstrapFailure(LwM2mServer bsserver, BootstrapRequest request, ResponseCode responseCode, String errorMessage, Exception cause) {
// No implementation needed
}
@Override
public void onBootstrapTimeout(ServerIdentity bsserver, BootstrapRequest request) {}
public void onBootstrapTimeout(LwM2mServer bsserver, BootstrapRequest request) {
// No implementation needed
}
@Override
public void onRegistrationStarted(ServerIdentity server, RegisterRequest request) {
public void onRegistrationStarted(LwM2mServer server, RegisterRequest request) {
log.debug("onRegistrationStarted [{}]", request.getEndpointName());
}
@Override
public void onRegistrationSuccess(ServerIdentity server, RegisterRequest request, String registrationID) {
public void onRegistrationSuccess(LwM2mServer server, RegisterRequest request, String registrationID) {
log.debug("onRegistrationSuccess [{}] [{}]", request.getEndpointName(), registrationID);
}
@Override
public void onRegistrationFailure(ServerIdentity server, RegisterRequest request, ResponseCode responseCode, String errorMessage, Exception cause) {
public void onRegistrationFailure(LwM2mServer server, RegisterRequest request, ResponseCode responseCode, String errorMessage, Exception cause) {
log.debug("onRegistrationFailure [{}] [{}] [{}]", request.getEndpointName(), responseCode, errorMessage);
}
@Override
public void onRegistrationTimeout(ServerIdentity server, RegisterRequest request) {
public void onRegistrationTimeout(LwM2mServer server, RegisterRequest request) {
log.debug("onRegistrationTimeout [{}]", request.getEndpointName());
}
@Override
public void onUpdateStarted(ServerIdentity server, UpdateRequest request) {
public void onUpdateStarted(LwM2mServer server, UpdateRequest request) {
log.debug("onUpdateStarted [{}]", request.getRegistrationId());
}
@Override
public void onUpdateSuccess(ServerIdentity server, UpdateRequest request) {
public void onUpdateSuccess(LwM2mServer server, UpdateRequest request) {
log.debug("onUpdateSuccess [{}]", request.getRegistrationId());
}
@Override
public void onUpdateFailure(ServerIdentity server, UpdateRequest request, ResponseCode responseCode, String errorMessage, Exception cause) {
public void onUpdateFailure(LwM2mServer server, UpdateRequest request, ResponseCode responseCode, String errorMessage, Exception cause) {
log.debug("onUpdateFailure [{}]", request.getRegistrationId());
}
@Override
public void onUpdateTimeout(ServerIdentity server, UpdateRequest request) {
public void onUpdateTimeout(LwM2mServer server, UpdateRequest request) {
log.debug("onUpdateTimeout [{}]", request.getRegistrationId());
}
@Override
public void onDeregistrationStarted(ServerIdentity server, DeregisterRequest request) {
public void onDeregistrationStarted(LwM2mServer server, DeregisterRequest request) {
log.debug("onDeregistrationStarted [{}]", request.getRegistrationId());
}
@Override
public void onDeregistrationSuccess(ServerIdentity server, DeregisterRequest request) {
log.debug("onDeregistrationStarted [{}]", request.getRegistrationId());
public void onDeregistrationSuccess(LwM2mServer server, DeregisterRequest request) {
log.debug("onDeregistrationSuccess [{}]", request.getRegistrationId());
}
@Override
public void onDeregistrationFailure(ServerIdentity server, DeregisterRequest request, ResponseCode responseCode, String errorMessage, Exception cause) {
public void onDeregistrationFailure(LwM2mServer server, DeregisterRequest request, ResponseCode responseCode, String errorMessage, Exception cause) {
log.debug("onDeregistrationFailure [{}] [{}] [{}]", request.getRegistrationId(), responseCode, errorMessage);
}
@Override
public void onDeregistrationTimeout(ServerIdentity server, DeregisterRequest request) {
public void onDeregistrationTimeout(LwM2mServer server, DeregisterRequest request) {
log.debug("onDeregistrationTimeout [{}]", request.getRegistrationId());
}
@ -224,7 +230,6 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable {
public void onUnexpectedError(Throwable unexpectedError) {
log.debug("onUnexpectedError [{}]", unexpectedError.toString());
}
};
leshanClient.addObserver(observer);
@ -239,17 +244,17 @@ public class Lwm2mClient extends BaseInstanceEnabler implements Destroyable {
}
@Override
public ReadResponse read(ServerIdentity identity, int resourceId) {
public ReadResponse read(LwM2mServer server, int resourceId) {
if (supportedResources.contains(resourceId)) {
return ReadResponse.success(resourceId, data);
}
return super.read(identity, resourceId);
return super.read(server, resourceId);
}
@SneakyThrows
public void send(String data, int resource) {
this.data = data;
fireResourcesChange(resource);
fireResourceChange(resource);
}
@Override

12
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java

@ -55,8 +55,8 @@ public class ContainerTestSuite {
private static final String TB_JS_EXECUTOR_LOG_REGEXP = ".*template started.*";
private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(400);
private DockerComposeContainer<?> testContainer;
private ThingsBoardDbInstaller installTb;
private DockerComposeContainer<?> testContainer;
private ThingsBoardDbInstaller installTb;
private boolean isActive;
private static ContainerTestSuite containerTestSuite;
@ -194,7 +194,7 @@ public class ContainerTestSuite {
setActive(true);
} catch (Exception e) {
log.error("Failed to create test container", e);
fail("Failed to create test container");
fail("Failed to create test container", e);
}
}
@ -263,7 +263,7 @@ public class ContainerTestSuite {
log.info("Trying to delete temp dir {}", targetDir);
FileUtils.deleteDirectory(new File(targetDir));
} catch (IOException e) {
log.error("Can't delete temp directory " + targetDir, e);
log.error("Can't delete temp directory {}", targetDir, e);
}
}
@ -286,8 +286,8 @@ public class ContainerTestSuite {
FileUtils.writeStringToFile(file, outputContent, StandardCharsets.UTF_8);
assertThat(FileUtils.readFileToString(file, StandardCharsets.UTF_8), is(outputContent));
} catch (IOException e) {
log.error("failed to update file " + sourceFilename, e);
fail("failed to update file");
log.error("failed to update file {}", sourceFilename, e);
fail("failed to update file", e);
}
}

15
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java

@ -42,7 +42,6 @@ import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientCallback;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttHandler;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceProfileProvisionType;
@ -82,7 +81,6 @@ import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.fail;
import static org.thingsboard.server.common.data.DataConstants.DEVICE;
import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype;
@ -301,7 +299,7 @@ public class MqttClientTest extends AbstractContainerTest {
assertThat(Objects.requireNonNull(requestFromServer).getMessage()).isEqualTo("{\"method\":\"getValue\",\"params\":true}");
Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
int requestId = Integer.parseInt(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
JsonObject clientResponse = new JsonObject();
clientResponse.addProperty("response", "someResponse");
// Send a response to the server's RPC request
@ -340,7 +338,7 @@ public class MqttClientTest extends AbstractContainerTest {
assertThat(Objects.requireNonNull(requestFromServer).getMessage()).isEqualTo("{\"method\":\"getValue\",\"params\":true}");
Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
int requestId = Integer.parseInt(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
JsonObject clientResponse = new JsonObject();
clientResponse.addProperty("response", "someResponse");
// Send a response to the server's RPC request
@ -520,13 +518,13 @@ public class MqttClientTest extends AbstractContainerTest {
mqttClient.on("/provision/response", listener, MqttQoS.AT_LEAST_ONCE).get(3 * timeoutMultiplier, TimeUnit.SECONDS);
TimeUnit.SECONDS.sleep(2 * timeoutMultiplier);
assertThat(subAckResult[0]).isNotNull();
assertThat(MqttReasonCodes.SubAck.GRANTED_QOS_1.equals(subAckResult[0]));
assertThat(MqttReasonCodes.SubAck.GRANTED_QOS_1).isEqualTo(subAckResult[0]);
subAckResult[0] = null;
mqttClient.on("v1/devices/me/attributes", listener, MqttQoS.AT_LEAST_ONCE).get(3 * timeoutMultiplier, TimeUnit.SECONDS);
TimeUnit.SECONDS.sleep(2 * timeoutMultiplier);
assertThat(subAckResult[0]).isNotNull();
assertThat(MqttReasonCodes.SubAck.TOPIC_FILTER_INVALID.equals(subAckResult[0]));
assertThat(MqttReasonCodes.SubAck.TOPIC_FILTER_INVALID).isEqualTo(subAckResult[0]);
testRestClient.deleteDeviceIfExists(device.getId());
updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED);
@ -596,7 +594,7 @@ public class MqttClientTest extends AbstractContainerTest {
.await()
.alias("Check device disconnect.")
.atMost(TIMEOUT*timeoutMultiplier, TimeUnit.SECONDS)
.until(() -> returnCodeByteValue.size() > 0);
.until(() -> !returnCodeByteValue.isEmpty());
assertThat(returnCodeByteValueSecondClient).isEmpty();
assertThat(returnCodeByteValue).isNotEmpty();
@ -663,7 +661,7 @@ public class MqttClientTest extends AbstractContainerTest {
.stream()
.filter(RuleChain::isRoot)
.findFirst();
if (!defaultRuleChain.isPresent()) {
if (defaultRuleChain.isEmpty()) {
fail("Root rule chain wasn't found");
}
return defaultRuleChain.get().getId();
@ -717,6 +715,7 @@ public class MqttClientTest extends AbstractContainerTest {
clientConfig.setClientId("MQTT client from test");
clientConfig.setUsername(username);
clientConfig.setProtocolVersion(mqttVersion);
clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(3, 5000L, 0.15d)); // same as defaults in thingsboard.yml as of time of this writing
MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor);
if (connect) {
mqttClient.connect(TRANSPORT_HOST, TRANSPORT_PORT).get();

14
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java

@ -39,7 +39,6 @@ import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttHandler;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.DeviceId;
@ -65,7 +64,6 @@ import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.DataConstants.DEVICE;
import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultGatewayPrototype;
@ -76,7 +74,6 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
private MqttClient mqttClient;
private Device createdDevice;
private MqttMessageListener listener;
private JsonParser jsonParser = new JsonParser();
AbstractListeningExecutor handlerExecutor;
@ -100,7 +97,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
}
@AfterMethod
public void removeGateway() {
public void removeGateway() {
testRestClient.deleteDeviceIfExists(this.gatewayDevice.getId());
testRestClient.deleteDeviceIfExists(this.createdDevice.getId());
this.listener = null;
@ -197,7 +194,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get();
event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
JsonObject responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
JsonObject responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
assertThat(responseData.has("value")).isTrue();
assertThat(responseData.get("value").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString());
@ -213,7 +210,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get();
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get();
event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
assertThat(responseData.has("values")).isTrue();
assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString());
@ -231,7 +228,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get();
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get();
event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
assertThat(responseData.has("values")).isTrue();
assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString());
@ -390,7 +387,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(gatewayAttributesRequest.toString().getBytes())).get();
MqttEvent clientAttributeEvent = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
assertThat(clientAttributeEvent).isNotNull();
JsonObject responseMessage = new JsonParser().parse(Objects.requireNonNull(clientAttributeEvent).getMessage()).getAsJsonObject();
JsonObject responseMessage = JsonParser.parseString(Objects.requireNonNull(clientAttributeEvent).getMessage()).getAsJsonObject();
assertThat(responseMessage.get("id").getAsInt()).isEqualTo(messageId);
assertThat(responseMessage.get("device").getAsString()).isEqualTo(createdDevice.getName());
@ -427,6 +424,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
clientConfig.setOwnerId(getOwnerId());
clientConfig.setClientId("MQTT client from test");
clientConfig.setUsername(deviceCredentials.getCredentialsId());
clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(3, 5000L, 0.15d)); // same as defaults in thingsboard.yml as of time of this writing
MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor);
mqttClient.connect("localhost", 1883).get();
return mqttClient;

20
netty-mqtt/pom.xml

@ -87,6 +87,26 @@
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>software.xdev</groupId>
<artifactId>testcontainers-junit4-mock</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>hivemq</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

24
netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2025 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.mqtt;
public class MaxRetransmissionsReachedException extends RuntimeException {
public MaxRetransmissionsReachedException(String message) {
super(message);
}
}

30
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java

@ -57,7 +57,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) throws Exception {
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) {
if (msg.decoderResult().isSuccess()) {
switch (msg.fixedHeader().messageType()) {
case CONNACK:
@ -120,6 +120,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
this.client.getClientConfig().getUsername(),
this.client.getClientConfig().getPassword() != null ? this.client.getClientConfig().getPassword().getBytes(CharsetUtil.UTF_8) : null
);
log.debug("{} Sending CONNECT", client.getClientConfig().getOwnerId());
ctx.channel().writeAndFlush(new MqttConnectMessage(fixedHeader, variableHeader, payload));
}
@ -173,6 +174,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
private void handleConack(Channel channel, MqttConnAckMessage message) {
log.debug("{} Handling CONNACK", client.getClientConfig().getOwnerId());
switch (message.variableHeader().connectReturnCode()) {
case CONNECTION_ACCEPTED:
this.connectFuture.setSuccess(new MqttConnectResult(true, MqttConnectReturnCode.CONNECTION_ACCEPTED, channel.closeFuture()));
@ -219,9 +221,9 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
pendingSubscription.onSubackReceived();
for (MqttPendingSubscription.MqttPendingHandler handler : pendingSubscription.getHandlers()) {
MqttSubscription subscription = new MqttSubscription(pendingSubscription.getTopic(), handler.getHandler(), handler.isOnce());
MqttSubscription subscription = new MqttSubscription(pendingSubscription.getTopic(), handler.handler(), handler.once());
this.client.getSubscriptions().put(pendingSubscription.getTopic(), subscription);
this.client.getHandlerToSubscription().put(handler.getHandler(), subscription);
this.client.getHandlerToSubscription().put(handler.handler(), subscription);
}
this.client.getPendingSubscribeTopics().remove(pendingSubscription.getTopic());
@ -282,17 +284,16 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
private void handlePuback(MqttPubAckMessage message) {
MqttPendingPublish pendingPublish = this.client.getPendingPublishes().get(message.variableHeader().messageId());
if (pendingPublish == null) {
return;
}
pendingPublish.getFuture().setSuccess(null);
pendingPublish.onPubackReceived();
this.client.getPendingPublishes().remove(message.variableHeader().messageId());
pendingPublish.getPayload().release();
if (this.client.getCallback() != null) {
this.client.getCallback().onPubAck(message);
}
log.trace("{} Handling PUBACK", client.getClientConfig().getOwnerId());
client.getPendingPublishes().computeIfPresent(message.variableHeader().messageId(), (__, pendingPublish) -> {
pendingPublish.getFuture().setSuccess(null);
pendingPublish.onPubackReceived();
pendingPublish.getPayload().release();
if (client.getCallback() != null) {
client.getCallback().onPubAck(message);
}
return null;
});
}
private void handlePubrec(Channel channel, MqttMessage message) {
@ -335,6 +336,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
private void handleDisconnect(MqttMessage message) {
log.debug("{} Handling DISCONNECT", client.getClientConfig().getOwnerId());
if (this.client.getCallback() != null) {
this.client.getCallback().onDisconnect(message);
}

2
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java

@ -184,7 +184,7 @@ public interface MqttClient {
* @param config The config object to use while looking for settings
* @param defaultHandler The handler for incoming messages that do not match any topic subscriptions
*/
static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler, ListeningExecutor handlerExecutor){
static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler, ListeningExecutor handlerExecutor) {
return new MqttClientImpl(config, defaultHandler, handlerExecutor);
}

20
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java

@ -47,6 +47,26 @@ public final class MqttClientConfig {
private long reconnectDelay = 1L;
private int maxBytesInMessage = 8092;
@Getter
@Setter
private RetransmissionConfig retransmissionConfig;
public record RetransmissionConfig(int maxAttempts, long initialDelayMillis, double jitterFactor) {
public RetransmissionConfig {
if (maxAttempts < 0) {
throw new IllegalArgumentException("Max retransmission attempts (maxAttempts) must be zero or greater, but was " + maxAttempts);
}
if (initialDelayMillis < 0) {
throw new IllegalArgumentException("Initial retransmission delay (initialDelayMillis) must be zero or greater, but was " + initialDelayMillis);
}
if (jitterFactor < 0) {
throw new IllegalArgumentException("Jitter factor (jitterFactor) must be zero or greater, but was " + jitterFactor);
}
}
}
public MqttClientConfig() {
this(null);
}

90
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java

@ -17,6 +17,7 @@ package org.thingsboard.mqtt;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
@ -80,6 +81,8 @@ final class MqttClientImpl implements MqttClient {
private final MqttHandler defaultHandler;
private final ReconnectStrategy reconnectStrategy;
private EventLoopGroup eventLoop;
private volatile Channel channel;
@ -110,6 +113,7 @@ final class MqttClientImpl implements MqttClient {
this.clientConfig = clientConfig;
this.defaultHandler = defaultHandler;
this.handlerExecutor = handlerExecutor;
this.reconnectStrategy = new ReconnectStrategyExponential(getClientConfig().getReconnectDelay());
}
/**
@ -191,7 +195,10 @@ final class MqttClientImpl implements MqttClient {
if (reconnect) {
this.reconnect = true;
}
eventLoop.schedule((Runnable) () -> connect(host, port, reconnect), clientConfig.getReconnectDelay(), TimeUnit.SECONDS);
final long nextReconnectDelay = reconnectStrategy.getNextReconnectDelay();
log.info("[{}] Scheduling reconnect in [{}] sec", channel != null ? channel.id() : "UNKNOWN", nextReconnectDelay);
eventLoop.schedule((Runnable) () -> connect(host, port, reconnect), nextReconnectDelay, TimeUnit.SECONDS);
}
}
@ -384,8 +391,33 @@ final class MqttClientImpl implements MqttClient {
MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PUBLISH, false, qos, retain, 0);
MqttPublishVariableHeader variableHeader = new MqttPublishVariableHeader(topic, getNewMessageId().messageId());
MqttPublishMessage message = new MqttPublishMessage(fixedHeader, variableHeader, payload);
MqttPendingPublish pendingPublish = new MqttPendingPublish(variableHeader.packetId(), future,
payload.retain(), message, qos, () -> !pendingPublishes.containsKey(variableHeader.packetId()));
final var pendingPublish = MqttPendingPublish.builder()
.messageId(variableHeader.packetId())
.future(future)
.payload(payload.retain())
.message(message)
.qos(qos)
.ownerId(clientConfig.getOwnerId())
.retransmissionConfig(clientConfig.getRetransmissionConfig())
.pendingOperation(new PendingOperation() {
@Override
public boolean isCancelled() {
return !pendingPublishes.containsKey(variableHeader.packetId());
}
@Override
public void onMaxRetransmissionAttemptsReached() {
pendingPublishes.computeIfPresent(variableHeader.packetId(), (__, pendingPublish) -> {
var message = "Unable to deliver publish message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)"
.formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.packetId());
pendingPublish.getFuture().tryFailure(new MaxRetransmissionsReachedException(message));
pendingPublish.getPayload().release();
return null;
});
}
}).build();
this.pendingPublishes.put(pendingPublish.getMessageId(), pendingPublish);
ChannelFuture channelFuture = this.sendAndFlushPacket(message);
@ -499,9 +531,30 @@ final class MqttClientImpl implements MqttClient {
MqttSubscribePayload payload = new MqttSubscribePayload(Collections.singletonList(subscription));
MqttSubscribeMessage message = new MqttSubscribeMessage(fixedHeader, variableHeader, payload);
final MqttPendingSubscription pendingSubscription = new MqttPendingSubscription(future, topic, message,
() -> !pendingSubscriptions.containsKey(variableHeader.messageId()));
pendingSubscription.addHandler(handler, once);
final var pendingSubscription = MqttPendingSubscription.builder()
.future(future)
.topic(topic)
.handlers(Sets.newHashSet(new MqttPendingSubscription.MqttPendingHandler(handler, once)))
.subscribeMessage(message)
.ownerId(clientConfig.getOwnerId())
.retransmissionConfig(clientConfig.getRetransmissionConfig())
.pendingOperation(new PendingOperation() {
@Override
public boolean isCancelled() {
return !pendingSubscriptions.containsKey(variableHeader.messageId());
}
@Override
public void onMaxRetransmissionAttemptsReached() {
pendingSubscriptions.computeIfPresent(variableHeader.messageId(), (__, pendingSubscription) -> {
var message = "Unable to deliver subscribe message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)"
.formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.messageId());
pendingSubscription.getFuture().tryFailure(new MaxRetransmissionsReachedException(message));
return null;
});
}
}).build();
this.pendingSubscriptions.put(variableHeader.messageId(), pendingSubscription);
this.pendingSubscribeTopics.add(topic);
pendingSubscription.setSent(this.sendAndFlushPacket(message) != null); //If not sent, we will send it when the connection is opened
@ -518,8 +571,29 @@ final class MqttClientImpl implements MqttClient {
MqttUnsubscribePayload payload = new MqttUnsubscribePayload(Collections.singletonList(topic));
MqttUnsubscribeMessage message = new MqttUnsubscribeMessage(fixedHeader, variableHeader, payload);
MqttPendingUnsubscription pendingUnsubscription = new MqttPendingUnsubscription(promise, topic, message,
() -> !pendingServerUnsubscribes.containsKey(variableHeader.messageId()));
final var pendingUnsubscription = MqttPendingUnsubscription.builder()
.future(promise)
.topic(topic)
.unsubscribeMessage(message)
.ownerId(clientConfig.getOwnerId())
.retransmissionConfig(clientConfig.getRetransmissionConfig())
.pendingOperation(new PendingOperation() {
@Override
public boolean isCancelled() {
return !pendingServerUnsubscribes.containsKey(variableHeader.messageId());
}
@Override
public void onMaxRetransmissionAttemptsReached() {
pendingServerUnsubscribes.computeIfPresent(variableHeader.messageId(), (__, pendingUnsubscription) -> {
var message = "Unable to deliver unsubscribe message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)"
.formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.messageId());
pendingUnsubscription.getFuture().tryFailure(new MaxRetransmissionsReachedException(message));
return null;
});
}
}).build();
this.pendingServerUnsubscribes.put(variableHeader.messageId(), pendingUnsubscription);
pendingUnsubscription.startRetransmissionTimer(this.eventLoop.next(), this::sendAndFlushPacket);

3
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java

@ -17,7 +17,9 @@ package org.thingsboard.mqtt;
import io.netty.channel.ChannelFuture;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import lombok.ToString;
@ToString
@SuppressWarnings({"WeakerAccess", "unused"})
public final class MqttConnectResult {
@ -42,4 +44,5 @@ public final class MqttConnectResult {
public ChannelFuture getCloseFuture() {
return closeFuture;
}
}

134
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java

@ -21,9 +21,13 @@ import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttPublishMessage;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.concurrent.Promise;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import java.util.function.Consumer;
@Getter(AccessLevel.PACKAGE)
final class MqttPendingPublish {
private final int messageId;
@ -32,80 +36,126 @@ final class MqttPendingPublish {
private final MqttPublishMessage message;
private final MqttQoS qos;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttPublishMessage> publishRetransmissionHandler;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttMessage> pubrelRetransmissionHandler;
@Setter(AccessLevel.PACKAGE)
private boolean sent = false;
MqttPendingPublish(int messageId, Promise<Void> future, ByteBuf payload, MqttPublishMessage message, MqttQoS qos, PendingOperation operation) {
private MqttPendingPublish(
int messageId,
Promise<Void> future,
ByteBuf payload,
MqttPublishMessage message,
MqttQoS qos,
String ownerId,
MqttClientConfig.RetransmissionConfig retransmissionConfig,
PendingOperation pendingOperation
) {
this.messageId = messageId;
this.future = future;
this.payload = payload;
this.message = message;
this.qos = qos;
this.publishRetransmissionHandler = new RetransmissionHandler<>(operation);
this.publishRetransmissionHandler.setOriginalMessage(message);
this.pubrelRetransmissionHandler = new RetransmissionHandler<>(operation);
}
int getMessageId() {
return messageId;
}
Promise<Void> getFuture() {
return future;
}
ByteBuf getPayload() {
return payload;
}
boolean isSent() {
return sent;
}
void setSent(boolean sent) {
this.sent = sent;
}
MqttPublishMessage getMessage() {
return message;
}
MqttQoS getQos() {
return qos;
publishRetransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, pendingOperation, ownerId);
publishRetransmissionHandler.setOriginalMessage(message);
pubrelRetransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, pendingOperation, ownerId);
}
void startPublishRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
this.publishRetransmissionHandler.setHandle(((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttPublishMessage(fixedHeader, originalMessage.variableHeader(), this.payload.retain()))));
this.publishRetransmissionHandler.start(eventLoop);
publishRetransmissionHandler.setHandler(((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttPublishMessage(fixedHeader, originalMessage.variableHeader(), payload.retain()))));
publishRetransmissionHandler.start(eventLoop);
}
void onPubackReceived() {
this.publishRetransmissionHandler.stop();
publishRetransmissionHandler.stop();
}
void setPubrelMessage(MqttMessage pubrelMessage) {
this.pubrelRetransmissionHandler.setOriginalMessage(pubrelMessage);
pubrelRetransmissionHandler.setOriginalMessage(pubrelMessage);
}
void startPubrelRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
this.pubrelRetransmissionHandler.setHandle((fixedHeader, originalMessage) ->
pubrelRetransmissionHandler.setHandler((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttMessage(fixedHeader, originalMessage.variableHeader())));
this.pubrelRetransmissionHandler.start(eventLoop);
pubrelRetransmissionHandler.start(eventLoop);
}
void onPubcompReceived() {
this.pubrelRetransmissionHandler.stop();
pubrelRetransmissionHandler.stop();
}
void onChannelClosed() {
this.publishRetransmissionHandler.stop();
this.pubrelRetransmissionHandler.stop();
publishRetransmissionHandler.stop();
pubrelRetransmissionHandler.stop();
if (payload != null) {
payload.release();
}
}
static Builder builder() {
return new Builder();
}
static class Builder {
private int messageId;
private Promise<Void> future;
private ByteBuf payload;
private MqttPublishMessage message;
private MqttQoS qos;
private String ownerId;
private MqttClientConfig.RetransmissionConfig retransmissionConfig;
private PendingOperation pendingOperation;
Builder messageId(int messageId) {
this.messageId = messageId;
return this;
}
Builder future(Promise<Void> future) {
this.future = future;
return this;
}
Builder payload(ByteBuf payload) {
this.payload = payload;
return this;
}
Builder message(MqttPublishMessage message) {
this.message = message;
return this;
}
Builder qos(MqttQoS qos) {
this.qos = qos;
return this;
}
Builder ownerId(String ownerId) {
this.ownerId = ownerId;
return this;
}
Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) {
this.retransmissionConfig = retransmissionConfig;
return this;
}
Builder pendingOperation(PendingOperation pendingOperation) {
this.pendingOperation = pendingOperation;
return this;
}
MqttPendingPublish build() {
return new MqttPendingPublish(messageId, future, payload, message, qos, ownerId, retransmissionConfig, pendingOperation);
}
}
}

119
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java

@ -18,90 +18,123 @@ package org.thingsboard.mqtt;
import io.netty.channel.EventLoop;
import io.netty.handler.codec.mqtt.MqttSubscribeMessage;
import io.netty.util.concurrent.Promise;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import static java.util.Objects.requireNonNullElseGet;
@Getter(AccessLevel.PACKAGE)
final class MqttPendingSubscription {
private final Promise<Void> future;
private final String topic;
private final Set<MqttPendingHandler> handlers = new HashSet<>();
private final Set<MqttPendingHandler> handlers;
private final MqttSubscribeMessage subscribeMessage;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttSubscribeMessage> retransmissionHandler;
@Setter(AccessLevel.PACKAGE)
private boolean sent = false;
MqttPendingSubscription(Promise<Void> future, String topic, MqttSubscribeMessage message, PendingOperation operation) {
private MqttPendingSubscription(
Promise<Void> future,
String topic,
Set<MqttPendingHandler> handlers,
MqttSubscribeMessage subscribeMessage,
String ownerId,
MqttClientConfig.RetransmissionConfig retransmissionConfig,
PendingOperation operation
) {
this.future = future;
this.topic = topic;
this.subscribeMessage = message;
this.handlers = requireNonNullElseGet(handlers, HashSet::new);
this.subscribeMessage = subscribeMessage;
this.retransmissionHandler = new RetransmissionHandler<>(operation);
this.retransmissionHandler.setOriginalMessage(message);
retransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, operation, ownerId);
retransmissionHandler.setOriginalMessage(subscribeMessage);
}
Promise<Void> getFuture() {
return future;
}
record MqttPendingHandler(MqttHandler handler, boolean once) {}
String getTopic() {
return topic;
void addHandler(MqttHandler handler, boolean once) {
handlers.add(new MqttPendingHandler(handler, once));
}
boolean isSent() {
return sent;
void startRetransmitTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
if (sent) { // If the packet is sent, we can start the retransmission timer
retransmissionHandler.setHandler((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttSubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
retransmissionHandler.start(eventLoop);
}
}
void setSent(boolean sent) {
this.sent = sent;
void onSubackReceived() {
retransmissionHandler.stop();
}
MqttSubscribeMessage getSubscribeMessage() {
return subscribeMessage;
void onChannelClosed() {
retransmissionHandler.stop();
}
void addHandler(MqttHandler handler, boolean once) {
this.handlers.add(new MqttPendingHandler(handler, once));
static Builder builder() {
return new Builder();
}
Set<MqttPendingHandler> getHandlers() {
return handlers;
}
static class Builder {
void startRetransmitTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
if (this.sent) { //If the packet is sent, we can start the retransmit timer
this.retransmissionHandler.setHandle((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttSubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
this.retransmissionHandler.start(eventLoop);
private Promise<Void> future;
private String topic;
private Set<MqttPendingHandler> handlers;
private MqttSubscribeMessage subscribeMessage;
private String ownerId;
private PendingOperation pendingOperation;
private MqttClientConfig.RetransmissionConfig retransmissionConfig;
Builder future(Promise<Void> future) {
this.future = future;
return this;
}
}
void onSubackReceived() {
this.retransmissionHandler.stop();
}
Builder topic(String topic) {
this.topic = topic;
return this;
}
final class MqttPendingHandler {
private final MqttHandler handler;
private final boolean once;
Builder handlers(Set<MqttPendingHandler> handlers) {
this.handlers = handlers;
return this;
}
MqttPendingHandler(MqttHandler handler, boolean once) {
this.handler = handler;
this.once = once;
Builder subscribeMessage(MqttSubscribeMessage subscribeMessage) {
this.subscribeMessage = subscribeMessage;
return this;
}
MqttHandler getHandler() {
return handler;
Builder ownerId(String ownerId) {
this.ownerId = ownerId;
return this;
}
boolean isOnce() {
return once;
Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) {
this.retransmissionConfig = retransmissionConfig;
return this;
}
Builder pendingOperation(PendingOperation pendingOperation) {
this.pendingOperation = pendingOperation;
return this;
}
MqttPendingSubscription build() {
return new MqttPendingSubscription(future, topic, handlers, subscribeMessage, ownerId, retransmissionConfig, pendingOperation);
}
}
void onChannelClosed() {
this.retransmissionHandler.stop();
}
}

85
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java

@ -18,43 +18,96 @@ package org.thingsboard.mqtt;
import io.netty.channel.EventLoop;
import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage;
import io.netty.util.concurrent.Promise;
import lombok.AccessLevel;
import lombok.Getter;
import java.util.function.Consumer;
final class MqttPendingUnsubscription{
@Getter(AccessLevel.PACKAGE)
final class MqttPendingUnsubscription {
private final Promise<Void> future;
private final String topic;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttUnsubscribeMessage> retransmissionHandler;
MqttPendingUnsubscription(Promise<Void> future, String topic, MqttUnsubscribeMessage unsubscribeMessage, PendingOperation operation) {
private MqttPendingUnsubscription(
Promise<Void> future,
String topic,
MqttUnsubscribeMessage unsubscribeMessage,
String ownerId,
MqttClientConfig.RetransmissionConfig retransmissionConfig,
PendingOperation operation
) {
this.future = future;
this.topic = topic;
this.retransmissionHandler = new RetransmissionHandler<>(operation);
this.retransmissionHandler.setOriginalMessage(unsubscribeMessage);
retransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, operation, ownerId);
retransmissionHandler.setOriginalMessage(unsubscribeMessage);
}
Promise<Void> getFuture() {
return future;
void startRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
retransmissionHandler.setHandler((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttUnsubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
retransmissionHandler.start(eventLoop);
}
String getTopic() {
return topic;
void onUnsubackReceived() {
retransmissionHandler.stop();
}
void startRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
this.retransmissionHandler.setHandle((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttUnsubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
this.retransmissionHandler.start(eventLoop);
void onChannelClosed() {
retransmissionHandler.stop();
}
void onUnsubackReceived(){
this.retransmissionHandler.stop();
static Builder builder() {
return new Builder();
}
void onChannelClosed(){
this.retransmissionHandler.stop();
static class Builder {
private Promise<Void> future;
private String topic;
private MqttUnsubscribeMessage unsubscribeMessage;
private String ownerId;
private PendingOperation pendingOperation;
private MqttClientConfig.RetransmissionConfig retransmissionConfig;
Builder future(Promise<Void> future) {
this.future = future;
return this;
}
Builder topic(String topic) {
this.topic = topic;
return this;
}
Builder unsubscribeMessage(MqttUnsubscribeMessage unsubscribeMessage) {
this.unsubscribeMessage = unsubscribeMessage;
return this;
}
Builder ownerId(String ownerId) {
this.ownerId = ownerId;
return this;
}
Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) {
this.retransmissionConfig = retransmissionConfig;
return this;
}
Builder pendingOperation(PendingOperation pendingOperation) {
this.pendingOperation = pendingOperation;
return this;
}
MqttPendingUnsubscription build() {
return new MqttPendingUnsubscription(future, topic, unsubscribeMessage, ownerId, retransmissionConfig, pendingOperation);
}
}
}

17
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java

@ -42,12 +42,11 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter {
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (!(msg instanceof MqttMessage)) {
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (!(msg instanceof MqttMessage message)) {
ctx.fireChannelRead(msg);
return;
}
MqttMessage message = (MqttMessage) msg;
if (message.fixedHeader().messageType() == MqttMessageType.PINGREQ) {
this.handlePingReq(ctx.channel());
} else if (message.fixedHeader().messageType() == MqttMessageType.PINGRESP) {
@ -61,28 +60,29 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter {
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
super.userEventTriggered(ctx, evt);
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (evt instanceof IdleStateEvent event) {
switch (event.state()) {
case READER_IDLE:
log.debug("[{}] No reads were performed for specified period for channel {}", event.state(), ctx.channel().id());
this.sendPingReq(ctx.channel());
this.sendPingReq(ctx.channel(), event);
break;
case WRITER_IDLE:
log.debug("[{}] No writes were performed for specified period for channel {}", event.state(), ctx.channel().id());
this.sendPingReq(ctx.channel());
this.sendPingReq(ctx.channel(), event);
break;
}
}
}
private void sendPingReq(Channel channel) {
private void sendPingReq(Channel channel, IdleStateEvent idleEvent) {
log.trace("[{}] Sending ping request", channel.id());
MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PINGREQ, false, MqttQoS.AT_MOST_ONCE, false, 0);
channel.writeAndFlush(new MqttMessage(fixedHeader));
if (this.pingRespTimeout == null) {
log.trace("[{}] Scheduling disconnect due to {}", channel.id(), idleEvent);
this.pingRespTimeout = channel.eventLoop().schedule(() -> {
log.trace("[{}] Sending disconnect due to {}", channel.id(), idleEvent);
MqttFixedHeader fixedHeader2 = new MqttFixedHeader(MqttMessageType.DISCONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0);
channel.writeAndFlush(new MqttMessage(fixedHeader2)).addListener(ChannelFutureListener.CLOSE);
//TODO: what do when the connection is closed ?
@ -99,6 +99,7 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter {
private void handlePingResp(Channel channel) {
log.trace("[{}] Handling ping response", channel.id());
if (this.pingRespTimeout != null && !this.pingRespTimeout.isCancelled() && !this.pingRespTimeout.isDone()) {
log.trace("[{}] Cancelling disconnect due to idle event because ping response was received", channel.id());
this.pingRespTimeout.cancel(true);
this.pingRespTimeout = null;
}

4
netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java

@ -17,6 +17,8 @@ package org.thingsboard.mqtt;
public interface PendingOperation {
boolean isCanceled();
boolean isCancelled();
void onMaxRetransmissionAttemptsReached();
}

21
netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategy.java

@ -0,0 +1,21 @@
/**
* Copyright © 2016-2025 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.mqtt;
@FunctionalInterface
public interface ReconnectStrategy {
long getNextReconnectDelay();
}

82
netty-mqtt/src/main/java/org/thingsboard/mqtt/ReconnectStrategyExponential.java

@ -0,0 +1,82 @@
/**
* Copyright © 2016-2025 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.mqtt;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
@Getter
@Slf4j
public class ReconnectStrategyExponential implements ReconnectStrategy {
public static final int DEFAULT_RECONNECT_INTERVAL_SEC = 10;
public static final int MAX_RECONNECT_INTERVAL_SEC = 60;
public static final int EXP_MAX = 8;
public static final long JITTER_MAX = 1;
private final long reconnectIntervalMinSeconds;
private final long reconnectIntervalMaxSeconds;
private long lastDisconnectNanoTime = 0; //isotonic time
private long retryCount = 0;
public ReconnectStrategyExponential(long reconnectIntervalMinSeconds) {
this.reconnectIntervalMaxSeconds = calculateIntervalMax(reconnectIntervalMinSeconds);
this.reconnectIntervalMinSeconds = calculateIntervalMin(reconnectIntervalMinSeconds);
}
long calculateIntervalMax(long reconnectIntervalMinSeconds) {
return reconnectIntervalMinSeconds > MAX_RECONNECT_INTERVAL_SEC ? reconnectIntervalMinSeconds : MAX_RECONNECT_INTERVAL_SEC;
}
long calculateIntervalMin(long reconnectIntervalMinSeconds) {
return Math.min((reconnectIntervalMinSeconds > 0 ? reconnectIntervalMinSeconds : DEFAULT_RECONNECT_INTERVAL_SEC), this.reconnectIntervalMaxSeconds);
}
@Override
synchronized public long getNextReconnectDelay() {
final long currentNanoTime = getNanoTime();
final long coolDownSpentNanos = currentNanoTime - lastDisconnectNanoTime;
lastDisconnectNanoTime = currentNanoTime;
if (isCooledDown(coolDownSpentNanos)) {
retryCount = 0;
return reconnectIntervalMinSeconds;
}
return calculateNextReconnectDelay() + calculateJitter();
}
long calculateJitter() {
return ThreadLocalRandom.current().nextInt() >= 0 ? JITTER_MAX : 0;
}
long calculateNextReconnectDelay() {
return Math.min(reconnectIntervalMaxSeconds, reconnectIntervalMinSeconds + calculateExp(retryCount++));
}
long calculateExp(long e) {
return 1L << Math.min(e, EXP_MAX);
}
boolean isCooledDown(long coolDownSpentNanos) {
return TimeUnit.NANOSECONDS.toSeconds(coolDownSpentNanos) > reconnectIntervalMaxSeconds + reconnectIntervalMinSeconds;
}
long getNanoTime() {
return System.nanoTime();
}
}

101
netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java

@ -18,66 +18,119 @@ package org.thingsboard.mqtt;
import io.netty.channel.EventLoop;
import io.netty.handler.codec.mqtt.MqttFixedHeader;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttPublishVariableHeader;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.concurrent.ScheduledFuture;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
@Slf4j
@RequiredArgsConstructor
final class RetransmissionHandler<T extends MqttMessage> {
private volatile boolean stopped;
private final MqttClientConfig.RetransmissionConfig config;
private final PendingOperation pendingOperation;
private volatile boolean stopped;
private ScheduledFuture<?> timer;
private int timeout = 10;
private int attemptCount = 0;
@Setter
private BiConsumer<MqttFixedHeader, T> handler;
// the three fields below are used for logging only
private final String ownerId;
private String originalMessageId;
private long totalWaitingTimeMillis;
private T originalMessage;
void setOriginalMessage(T originalMessage) {
this.originalMessage = originalMessage;
var variableHeader = originalMessage.variableHeader();
if (variableHeader instanceof MqttMessageIdVariableHeader messageIdVariableHeader) {
originalMessageId = String.valueOf(messageIdVariableHeader.messageId());
} else if (variableHeader instanceof MqttPublishVariableHeader publishVariableHeader) {
originalMessageId = String.valueOf(publishVariableHeader.packetId());
} else {
originalMessageId = "N/A";
}
}
void start(EventLoop eventLoop) {
if (eventLoop == null) {
throw new NullPointerException("eventLoop");
}
if (this.handler == null) {
if (handler == null) {
throw new NullPointerException("handler");
}
this.timeout = 10;
this.startTimer(eventLoop);
log.debug("{}MessageID[{}] Starting retransmission handler", ownerId, originalMessageId);
startTimer(eventLoop);
}
private void startTimer(EventLoop eventLoop) {
if (stopped || pendingOperation.isCanceled()) {
if (stopped || pendingOperation.isCancelled()) {
return;
}
this.timer = eventLoop.schedule(() -> {
if (stopped || pendingOperation.isCanceled()) {
// Calculate the base delay using exponential backoff.
// For attemptCount == 0, delay = initial delay; for each subsequent attempt, the base delay doubles.
long baseDelay = config.initialDelayMillis() * (long) Math.pow(2, attemptCount);
// Apply jitter: random factor between (1 - jitterFactor) and (1 + jitterFactor).
double minFactor = 1.0 - config.jitterFactor();
double maxFactor = 1.0 + config.jitterFactor();
double randomFactor = config.jitterFactor() == 0 ? 1 : ThreadLocalRandom.current().nextDouble(minFactor, maxFactor);
long delayMillisWithJitter = (long) (baseDelay * randomFactor);
totalWaitingTimeMillis += delayMillisWithJitter;
timer = eventLoop.schedule(() -> {
if (stopped || pendingOperation.isCancelled()) {
return;
}
this.timeout += 5;
boolean isDup = this.originalMessage.fixedHeader().isDup();
if (this.originalMessage.fixedHeader().messageType() == MqttMessageType.PUBLISH && this.originalMessage.fixedHeader().qosLevel() != MqttQoS.AT_MOST_ONCE) {
isDup = true;
attemptCount++;
if (attemptCount > config.maxAttempts()) {
log.debug(
"{}MessageID[{}] Gave up after {} retransmission attempts; waited a total of {} ms without receiving acknowledgement",
ownerId, originalMessageId, config.maxAttempts(), totalWaitingTimeMillis
);
stop();
pendingOperation.onMaxRetransmissionAttemptsReached();
return;
}
MqttFixedHeader fixedHeader = new MqttFixedHeader(this.originalMessage.fixedHeader().messageType(), isDup, this.originalMessage.fixedHeader().qosLevel(), this.originalMessage.fixedHeader().isRetain(), this.originalMessage.fixedHeader().remainingLength());
handler.accept(fixedHeader, originalMessage);
log.debug("{}MessageID[{}] Retransmission attempt #{} out of {}", ownerId, originalMessageId, attemptCount, config.maxAttempts());
var originalFixedHeader = originalMessage.fixedHeader();
var newFixedHeader = new MqttFixedHeader(
originalFixedHeader.messageType(),
isDup(originalFixedHeader),
originalFixedHeader.qosLevel(),
originalFixedHeader.isRetain(),
originalFixedHeader.remainingLength()
);
handler.accept(newFixedHeader, originalMessage);
startTimer(eventLoop);
}, timeout, TimeUnit.SECONDS);
}, delayMillisWithJitter, TimeUnit.MILLISECONDS);
}
private static boolean isDup(MqttFixedHeader originalFixedHeader) {
return originalFixedHeader.isDup() || (originalFixedHeader.messageType() == MqttMessageType.PUBLISH && originalFixedHeader.qosLevel() != MqttQoS.AT_MOST_ONCE);
}
void stop() {
log.debug("{}MessageID[{}] Stopping retransmission handler", ownerId, originalMessageId);
stopped = true;
if (this.timer != null) {
this.timer.cancel(true);
if (timer != null) {
timer.cancel(true);
}
}
void setHandle(BiConsumer<MqttFixedHeader, T> runnable) {
this.handler = runnable;
}
void setOriginalMessage(T originalMessage) {
this.originalMessage = originalMessage;
}
}

210
netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java

@ -0,0 +1,210 @@
/**
* Copyright © 2016-2025 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.mqtt;
import com.google.common.util.concurrent.Futures;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.ResourceLeakDetector;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.Promise;
import lombok.extern.slf4j.Slf4j;
import org.awaitility.Awaitility;
import org.awaitility.core.ConditionTimeoutException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.hivemq.HiveMQContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.thingsboard.common.util.AbstractListeningExecutor;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@Testcontainers
class MqttClientTest {
final int randomPort = 0;
@Container
HiveMQContainer broker = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce").withTag("2025.2"));
MqttTestProxy proxy;
MqttClient client;
AbstractListeningExecutor handlerExecutor;
@BeforeAll
static void init() {
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);
}
@BeforeEach
void setup() {
handlerExecutor = new AbstractListeningExecutor() {
@Override
protected int getThreadPollSize() {
return 1;
}
};
handlerExecutor.init();
}
@AfterEach
void cleanup() {
if (client != null) {
client.disconnect();
client = null;
}
if (proxy != null) {
proxy.stop();
proxy = null;
}
handlerExecutor.destroy();
handlerExecutor = null;
}
@Test
void testConnectToBroker() {
// GIVEN
var clientConfig = new MqttClientConfig();
clientConfig.setOwnerId("Test[ConnectToBroker]");
clientConfig.setClientId("connect");
client = MqttClient.create(clientConfig, null, handlerExecutor);
// WHEN
Promise<MqttConnectResult> connectFuture = client.connect(broker.getHost(), broker.getMqttPort());
// THEN
assertThat(connectFuture).isNotNull();
Awaitility.await("waiting for client to connect")
.atMost(Duration.ofSeconds(10L))
.until(connectFuture::isDone);
assertThat(connectFuture.isSuccess()).isTrue();
MqttConnectResult actualConnectResult = connectFuture.getNow();
assertThat(actualConnectResult).isNotNull();
assertThat(actualConnectResult.isSuccess()).isTrue();
assertThat(actualConnectResult.getReturnCode()).isEqualTo(MqttConnectReturnCode.CONNECTION_ACCEPTED);
assertThat(client.isConnected()).isTrue();
}
@Test
void testDisconnectDueToKeepAliveIfNoActivity() {
// GIVEN
proxy = MqttTestProxy.builder()
.localPort(randomPort)
.brokerHost(broker.getHost())
.brokerPort(broker.getMqttPort())
.brokerToClientInterceptor(msg -> msg.fixedHeader().messageType() != MqttMessageType.PINGRESP) // drop all ping responses to simulate broker down
.build();
int idleTimeoutSeconds = 2;
var clientConfig = new MqttClientConfig();
clientConfig.setOwnerId("Test[KeepAliveDisconnect]");
clientConfig.setClientId("no-activity-disconnect");
clientConfig.setTimeoutSeconds(idleTimeoutSeconds);
clientConfig.setReconnect(false); // disable auto reconnect
client = MqttClient.create(clientConfig, null, handlerExecutor);
// WHEN-THEN
connect(broker.getHost(), proxy.getPort());
// no activity...
Awaitility.await("waiting for client to disconnect")
.pollDelay(Duration.ofSeconds(idleTimeoutSeconds * 2)) // 2 seconds to wait for the first idle event and then 2 seconds for scheduled disconnect to fire
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> assertThat(client.isConnected()).isFalse());
}
@Test
void testRetransmission() {
// GIVEN
proxy = MqttTestProxy.builder()
.localPort(randomPort)
.brokerHost(broker.getHost())
.brokerPort(broker.getMqttPort())
.brokerToClientInterceptor(msg -> msg.fixedHeader().messageType() != MqttMessageType.PUBACK) // drop all pubacks to allow retransmission to happen
.build();
// create client
var clientConfig = new MqttClientConfig();
clientConfig.setOwnerId("Test[Retransmission]");
clientConfig.setClientId("retransmission");
clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(1, 1000L, 0d));
client = MqttClient.create(clientConfig, null, handlerExecutor);
// connect to a broker
connect(broker.getHost(), proxy.getPort());
// subscribe to a topic
String topic = "test-topic";
List<ByteBuf> receivedMessages = Collections.synchronizedList(new ArrayList<>(2));
Future<Void> subscribeFuture = client.on(topic, (__, payload) -> {
receivedMessages.add(payload);
return Futures.immediateVoidFuture();
});
Awaitility.await("waiting for client to subscribe to a topic")
.atMost(Duration.ofSeconds(10L))
.until(subscribeFuture::isDone);
// WHEN
// publish a message
ByteBuf message = PooledByteBufAllocator.DEFAULT.buffer().writeBytes("test message".getBytes(StandardCharsets.UTF_8));
client.publish(topic, message, MqttQoS.AT_LEAST_ONCE);
// THEN
// wait enough time so that retransmission happens and stops
// if retransmission works incorrectly waiting 10 seconds allows for additional retransmissions to happen
try {
Awaitility.await("wait up to 10s, stop early if too many messages")
.atMost(Duration.ofSeconds(10L))
.pollInterval(Duration.ofMillis(100))
.until(() -> receivedMessages.size() > 2);
} catch (ConditionTimeoutException __) {
// didn't exceed 2 messages
}
assertThat(receivedMessages).size().describedAs("incorrect number of messages received, expected 2 (original plus one retransmitted)").isEqualTo(2);
}
private void connect(String host, int port) {
Promise<MqttConnectResult> connectFuture = client.connect(host, port);
Awaitility.await("waiting for client to connect")
.atMost(Duration.ofSeconds(10L))
.until(connectFuture::isSuccess);
}
}

63
netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java

@ -1,63 +0,0 @@
/**
* Copyright © 2016-2025 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.mqtt;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.DefaultEventLoop;
import io.netty.handler.timeout.IdleStateEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.TimeUnit;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.after;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MqttPingHandlerTest {
static final int KEEP_ALIVE_SECONDS = 0;
static final int PROCESS_SEND_DISCONNECT_MSG_TIME_MS = 500;
MqttPingHandler mqttPingHandler;
@BeforeEach
void setUp() {
mqttPingHandler = new MqttPingHandler(KEEP_ALIVE_SECONDS);
}
@Test
void givenChannelReaderIdleState_whenNoPingResponse_thenDisconnectClient() throws Exception {
ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
Channel channel = mock(Channel.class);
when(ctx.channel()).thenReturn(channel);
when(channel.eventLoop()).thenReturn(new DefaultEventLoop());
ChannelFuture channelFuture = mock(ChannelFuture.class);
when(channel.writeAndFlush(any())).thenReturn(channelFuture);
mqttPingHandler.userEventTriggered(ctx, IdleStateEvent.FIRST_READER_IDLE_STATE_EVENT);
verify(
channelFuture,
after(TimeUnit.SECONDS.toMillis(KEEP_ALIVE_SECONDS) + PROCESS_SEND_DISCONNECT_MSG_TIME_MS)
).addListener(eq(ChannelFutureListener.CLOSE));
}
}

202
netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java

@ -0,0 +1,202 @@
/**
* Copyright © 2016-2025 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.mqtt;
import io.netty.bootstrap.Bootstrap;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.mqtt.MqttDecoder;
import io.netty.handler.codec.mqtt.MqttEncoder;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.util.ReferenceCountUtil;
import lombok.extern.slf4j.Slf4j;
import java.net.InetSocketAddress;
import java.util.function.Predicate;
@Slf4j
public class MqttTestProxy {
private final EventLoopGroup bossGroup;
private final EventLoopGroup workerGroup;
private Channel clientToProxyChannel;
private Channel proxyToBrokerChannel;
private final int assignedPort;
private boolean stopped;
private final Predicate<MqttMessage> brokerToClientInterceptor;
private MqttTestProxy(Builder builder) {
log.info("Starting MQTT proxy...");
brokerToClientInterceptor = builder.brokerToClientInterceptor != null ? builder.brokerToClientInterceptor : msg -> true;
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup(1);
ServerBootstrap proxyBootstrap = new ServerBootstrap();
proxyBootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel channel) {
clientToProxyChannel = channel;
clientToProxyChannel.config().setAutoRead(false); // do not accept data before we connected to a broker
connectToBroker(builder.brokerHost, builder.brokerPort).addListener(future -> {
if (future.isSuccess()) {
clientToProxyChannel.pipeline().addLast("mqttDecoder", new MqttDecoder());
clientToProxyChannel.pipeline().addLast("mqttToBroker", new MqttRelayHandler(proxyToBrokerChannel, null));
clientToProxyChannel.pipeline().addLast("mqttEncoder", MqttEncoder.INSTANCE);
clientToProxyChannel.config().setAutoRead(true); // start accepting data for a client
} else {
log.error("Failed to connect to broker", future.cause());
clientToProxyChannel.close();
}
});
}
});
try {
Channel proxyChannel = proxyBootstrap.bind(builder.localPort).sync().channel();
assignedPort = ((InetSocketAddress) proxyChannel.localAddress()).getPort();
} catch (Exception e) {
log.error("Failed to start MQTT proxy", e);
throw new RuntimeException("Failed to start MQTT proxy", e);
}
log.info("MQTT proxy started on port {}", assignedPort);
}
private ChannelFuture connectToBroker(String brokerHost, int brokerPort) {
Bootstrap proxyToBrokerBootstrap = new Bootstrap();
proxyToBrokerBootstrap.group(workerGroup)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel channel) {
proxyToBrokerChannel = channel;
proxyToBrokerChannel.pipeline().addLast(new MqttDecoder());
proxyToBrokerChannel.pipeline().addLast("mqttToClient", new MqttRelayHandler(clientToProxyChannel, brokerToClientInterceptor));
proxyToBrokerChannel.pipeline().addLast(MqttEncoder.INSTANCE);
}
});
return proxyToBrokerBootstrap.connect(brokerHost, brokerPort);
}
private static class MqttRelayHandler extends SimpleChannelInboundHandler<MqttMessage> {
private final Channel targetChannel;
private final Predicate<MqttMessage> interceptor;
private MqttRelayHandler(Channel targetChannel, Predicate<MqttMessage> interceptor) {
this.targetChannel = targetChannel;
this.interceptor = interceptor;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) {
log.debug("Received message: {}", msg.fixedHeader().messageType());
if (interceptor == null || interceptor.test(msg)) {
if (targetChannel.isActive()) {
targetChannel.writeAndFlush(ReferenceCountUtil.retain(msg));
}
} else {
log.info("Dropping message: {}", msg.fixedHeader().messageType());
}
}
}
public void stop() {
if (stopped) {
log.info("MQTT proxy was already stopped");
return;
}
stopped = true;
log.info("Stopping MQTT proxy...");
if (clientToProxyChannel != null) {
clientToProxyChannel.close();
}
if (proxyToBrokerChannel != null) {
proxyToBrokerChannel.close();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
log.info("MQTT proxy stopped");
}
public int getPort() {
return assignedPort;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private int localPort;
private String brokerHost;
private int brokerPort;
private Predicate<MqttMessage> brokerToClientInterceptor;
public Builder localPort(int localPort) {
this.localPort = localPort;
return this;
}
public Builder brokerHost(String brokerHost) {
this.brokerHost = brokerHost;
return this;
}
public Builder brokerPort(int brokerPort) {
this.brokerPort = brokerPort;
return this;
}
public Builder brokerToClientInterceptor(Predicate<MqttMessage> interceptor) {
this.brokerToClientInterceptor = interceptor;
return this;
}
public MqttTestProxy build() {
return new MqttTestProxy(this);
}
}
}

95
netty-mqtt/src/test/java/org/thingsboard/mqtt/ReconnectStrategyExponentialTest.java

@ -0,0 +1,95 @@
/**
* Copyright © 2016-2025 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.mqtt;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;
import org.mockito.stubbing.Answer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.data.Offset.offset;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.willAnswer;
import static org.thingsboard.mqtt.ReconnectStrategyExponential.EXP_MAX;
import static org.thingsboard.mqtt.ReconnectStrategyExponential.JITTER_MAX;
@Slf4j
class ReconnectStrategyExponentialTest {
@Execution(ExecutionMode.SAME_THREAD) // just for convenient log reading
@ParameterizedTest
@ValueSource(ints = {1, 0, 60})
public void exponentialReconnectDelayTest(final int reconnectIntervalMinSeconds) {
final ReconnectStrategyExponential strategy = Mockito.spy(new ReconnectStrategyExponential(reconnectIntervalMinSeconds));
log.info("=== Reconnect delay test for ReconnectStrategyExponential({}) : calculated min [{}] max [{}] ===", reconnectIntervalMinSeconds, strategy.getReconnectIntervalMinSeconds(), strategy.getReconnectIntervalMaxSeconds());
final AtomicLong nanoTime = new AtomicLong(System.nanoTime());
willAnswer((x) -> nanoTime.get()).given(strategy).getNanoTime();
final LinkedBlockingDeque<Long> jittersCaptured = new LinkedBlockingDeque<>();
final LinkedBlockingDeque<Long> expCaptured = new LinkedBlockingDeque<>();
willAnswer(captureResult(jittersCaptured)).given(strategy).calculateJitter();
willAnswer(captureResult(expCaptured)).given(strategy).calculateExp(anyLong());
for (int phase = 0; phase < 3; phase++) {
log.info("== Phase {} ==", phase);
long previousDelay = 0;
for (int i = 0; i < EXP_MAX + 4; i++) {
final long nextReconnectDelay = strategy.getNextReconnectDelay();
nanoTime.addAndGet(TimeUnit.SECONDS.toNanos(nextReconnectDelay));
log.info("Retry [{}] Delay [{}] : min [{}] exp [{}] jitter [{}]", strategy.getRetryCount(), nextReconnectDelay, strategy.getReconnectIntervalMinSeconds(), expCaptured.peekLast(), jittersCaptured.peekLast());
assertThat(previousDelay).satisfiesAnyOf(
v -> assertThat(v).isLessThanOrEqualTo(nextReconnectDelay),
v -> assertThat(v).isCloseTo(nextReconnectDelay, offset(JITTER_MAX)) // Adjust tolerance as needed
);
previousDelay = nextReconnectDelay;
}
log.info("Jitters captured: {}", drainAll(jittersCaptured));
log.info("Exponents captured: {}", drainAll(expCaptured));
assertThat(previousDelay).isCloseTo(strategy.getReconnectIntervalMaxSeconds(), offset(JITTER_MAX));
final long coolDownPeriodSec = strategy.getReconnectIntervalMinSeconds() + strategy.getReconnectIntervalMaxSeconds() + 1;
log.info("Cooling down for [{}] seconds ...", coolDownPeriodSec);
nanoTime.addAndGet(TimeUnit.SECONDS.toNanos(coolDownPeriodSec));
assertThat(strategy.isCooledDown(TimeUnit.SECONDS.toNanos(coolDownPeriodSec))).as("cooled down").isTrue();
}
}
private Answer<Long> captureResult(Collection<Long> collection) {
return invocation -> {
long result = (long) invocation.callRealMethod();
collection.add(result);
return result;
};
}
private Collection<Long> drainAll(BlockingQueue<Long> jittersCaptured) {
Collection<Long> elements = new ArrayList<>();
jittersCaptured.drainTo(elements);
return elements;
}
}

151
netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java

@ -1,151 +0,0 @@
/**
* Copyright © 2016-2025 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.mqtt.integration;
import io.netty.buffer.Unpooled;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.Promise;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.thingsboard.common.util.AbstractListeningExecutor;
import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttConnectResult;
import org.thingsboard.mqtt.integration.server.MqttServer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@ResourceLock("port8885") // test MQTT server port
@Slf4j
public class MqttIntegrationTest {
static final String MQTT_HOST = "localhost";
static final int KEEPALIVE_TIMEOUT_SECONDS = 2;
static final long RECONNECT_DELAY_SECONDS = 10L;
EventLoopGroup eventLoopGroup;
MqttServer mqttServer;
MqttClient mqttClient;
AbstractListeningExecutor handlerExecutor;
@BeforeEach
public void init() throws Exception {
this.handlerExecutor = new AbstractListeningExecutor() {
@Override
protected int getThreadPollSize() {
return 4;
}
};
handlerExecutor.init();
this.eventLoopGroup = new NioEventLoopGroup();
this.mqttServer = new MqttServer();
this.mqttServer.init();
}
@AfterEach
public void destroy() throws InterruptedException {
if (this.mqttClient != null) {
this.mqttClient.disconnect();
}
if (this.mqttServer != null) {
this.mqttServer.shutdown();
}
if (this.eventLoopGroup != null) {
this.eventLoopGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS);
}
if (this.handlerExecutor != null) {
this.handlerExecutor.destroy();
}
}
@Test
public void givenActiveMqttClient_whenNoActivityForKeepAliveTimeout_thenDisconnectClient() throws Throwable {
//given
this.mqttClient = initClient();
log.warn("Sending publish messages...");
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
Thread.sleep(30);
Future<Void> pubFuture = publishMsg();
pubFuture.addListener(future -> latch.countDown());
}
log.warn("Waiting for messages acknowledgments...");
boolean awaitResult = latch.await(10, TimeUnit.SECONDS);
Assertions.assertTrue(awaitResult);
log.warn("Messages are delivered successfully...");
//when
log.warn("Starting idle period...");
Thread.sleep(5000);
//then
List<MqttMessageType> allReceivedEvents = this.mqttServer.getEventsFromClient();
long disconnectCount = allReceivedEvents.stream().filter(type -> type == MqttMessageType.DISCONNECT).count();
Assertions.assertEquals(1, disconnectCount);
}
private Future<Void> publishMsg() {
return this.mqttClient.publish(
"test/topic",
Unpooled.wrappedBuffer("payload".getBytes(StandardCharsets.UTF_8)),
MqttQoS.AT_MOST_ONCE);
}
private MqttClient initClient() throws Exception {
MqttClientConfig config = new MqttClientConfig();
config.setOwnerId("MqttIntegrationTest");
config.setTimeoutSeconds(KEEPALIVE_TIMEOUT_SECONDS);
config.setReconnectDelay(RECONNECT_DELAY_SECONDS);
MqttClient client = MqttClient.create(config, null, handlerExecutor);
client.setEventLoop(this.eventLoopGroup);
Promise<MqttConnectResult> connectFuture = client.connect(MQTT_HOST, this.mqttServer.getMqttPort());
String hostPort = MQTT_HOST + ":" + this.mqttServer.getMqttPort();
MqttConnectResult result;
try {
result = connectFuture.get(10, TimeUnit.SECONDS);
} catch (TimeoutException ex) {
connectFuture.cancel(true);
client.disconnect();
throw new RuntimeException(String.format("Failed to connect to MQTT server at %s.", hostPort));
}
if (!result.isSuccess()) {
connectFuture.cancel(true);
client.disconnect();
throw new RuntimeException(String.format("Failed to connect to MQTT server at %s. Result code is: %s", hostPort, result.getReturnCode()));
}
return client;
}
}

84
netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java

@ -1,84 +0,0 @@
/**
* Copyright © 2016-2025 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.mqtt.integration.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.mqtt.MqttDecoder;
import io.netty.handler.codec.mqtt.MqttEncoder;
import io.netty.handler.codec.mqtt.MqttMessageType;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@Slf4j
public class MqttServer {
@Getter
private final List<MqttMessageType> eventsFromClient = new CopyOnWriteArrayList<>();
@Getter
private final int mqttPort = 8885;
private Channel serverChannel;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
public void init() throws Exception {
log.info("Starting MQTT server on port {}...", mqttPort);
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast("decoder", new MqttDecoder(65536));
pipeline.addLast("encoder", MqttEncoder.INSTANCE);
MqttTransportHandler handler = new MqttTransportHandler(eventsFromClient);
pipeline.addLast(handler);
ch.closeFuture().addListener(handler);
}
})
.childOption(ChannelOption.SO_KEEPALIVE, true);
serverChannel = b.bind(mqttPort).sync().channel();
log.info("Mqtt transport started!");
}
public void shutdown() throws InterruptedException {
log.info("Stopping MQTT transport!");
try {
serverChannel.close().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
log.info("MQTT transport stopped!");
}
}

141
netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java

@ -1,141 +0,0 @@
/**
* Copyright © 2016-2025 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.mqtt.integration.server;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.mqtt.MqttConnAckMessage;
import io.netty.handler.codec.mqtt.MqttConnAckVariableHeader;
import io.netty.handler.codec.mqtt.MqttConnectMessage;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import io.netty.handler.codec.mqtt.MqttFixedHeader;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttPubAckMessage;
import io.netty.handler.codec.mqtt.MqttPublishMessage;
import io.netty.util.ReferenceCountUtil;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.UUID;
import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK;
import static io.netty.handler.codec.mqtt.MqttMessageType.CONNECT;
import static io.netty.handler.codec.mqtt.MqttMessageType.DISCONNECT;
import static io.netty.handler.codec.mqtt.MqttMessageType.PINGREQ;
import static io.netty.handler.codec.mqtt.MqttMessageType.PUBACK;
import static io.netty.handler.codec.mqtt.MqttMessageType.PUBLISH;
import static io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE;
@Slf4j
public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener<Future<? super Void>> {
private final List<MqttMessageType> eventsFromClient;
private final UUID sessionId;
MqttTransportHandler(List<MqttMessageType> eventsFromClient) {
this.sessionId = UUID.randomUUID();
this.eventsFromClient = eventsFromClient;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
log.trace("[{}] Processing msg: {}", sessionId, msg);
try {
if (msg instanceof MqttMessage) {
MqttMessage message = (MqttMessage) msg;
if (message.decoderResult().isSuccess()) {
processMqttMsg(ctx, message);
} else {
log.error("[{}] Message decoding failed: {}", sessionId, message.decoderResult().cause().getMessage());
ctx.close();
}
} else {
log.debug("[{}] Received non mqtt message: {}", sessionId, msg.getClass().getSimpleName());
ctx.close();
}
} finally {
ReferenceCountUtil.safeRelease(msg);
}
}
void processMqttMsg(ChannelHandlerContext ctx, MqttMessage msg) {
if (msg.fixedHeader() == null) {
ctx.close();
return;
}
switch (msg.fixedHeader().messageType()) {
case CONNECT:
eventsFromClient.add(CONNECT);
processConnect(ctx, (MqttConnectMessage) msg);
break;
case DISCONNECT:
eventsFromClient.add(DISCONNECT);
ctx.close();
break;
case PUBLISH:
// QoS 0 and 1 supported only here
eventsFromClient.add(PUBLISH);
MqttPublishMessage mqttPubMsg = (MqttPublishMessage) msg;
ack(ctx, mqttPubMsg.variableHeader().packetId());
break;
case PINGREQ:
// We will not handle PINGREQ and will not send any PINGRESP to simulate the MQTT server is down
eventsFromClient.add(PINGREQ);
break;
default:
break;
}
}
void processConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) {
String userName = msg.payload().userName();
String clientId = msg.payload().clientIdentifier();
log.warn("[{}][{}] Processing connect msg for client: {}!", sessionId, userName, clientId);
ctx.writeAndFlush(createMqttConnAckMsg(msg));
}
private MqttConnAckMessage createMqttConnAckMsg(MqttConnectMessage msg) {
MqttFixedHeader mqttFixedHeader =
new MqttFixedHeader(CONNACK, false, AT_MOST_ONCE, false, 0);
MqttConnAckVariableHeader mqttConnAckVariableHeader =
new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_ACCEPTED, !msg.variableHeader().isCleanSession());
return new MqttConnAckMessage(mqttFixedHeader, mqttConnAckVariableHeader);
}
private void ack(ChannelHandlerContext ctx, int msgId) {
if (msgId > 0) {
ctx.writeAndFlush(createMqttPubAckMsg(msgId));
}
}
public static MqttPubAckMessage createMqttPubAckMsg(int requestId) {
MqttFixedHeader mqttFixedHeader =
new MqttFixedHeader(PUBACK, false, AT_MOST_ONCE, false, 0);
MqttMessageIdVariableHeader mqttMsgIdVariableHeader =
MqttMessageIdVariableHeader.from(requestId);
return new MqttPubAckMessage(mqttFixedHeader, mqttMsgIdVariableHeader);
}
@Override
public void operationComplete(Future<? super Void> future) {
log.trace("[{}] Channel closed!", sessionId);
}
}

3
netty-mqtt/src/test/resources/junit-platform.properties

@ -1,3 +0,0 @@
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent

51
pom.xml

@ -42,13 +42,14 @@
<jakarta.xml.bind-api.version>4.0.2</jakarta.xml.bind-api.version>
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jaxb-runtime.version>4.0.5</jaxb-runtime.version>
<tomcat.version>10.1.39</tomcat.version> <!--Remove after update spring-boot to new version-->
<tomcat.version>10.1.40</tomcat.version> <!-- Vulnerability fix, Remove after update spring-boot to new version-->
<net.minidev.json-smart>2.5.2</net.minidev.json-smart> <!-- Vulnerability fix, CVE-2024-57699, Remove after update spring-boot 3.2.12 to a newer version-->
<spring-boot.version>3.2.12</spring-boot.version>
<spring-data.version>3.2.12</spring-data.version>
<spring-data-redis.version>3.2.12</spring-data-redis.version>
<spring.version>6.1.15</spring.version>
<spring-redis.version>6.2.11</spring-redis.version>
<spring-security.version>6.2.8</spring-security.version>
<spring-security.version>6.3.8</spring-security.version>
<jedis.version>5.1.5</jedis.version>
<jjwt.version>0.12.5</jjwt.version>
<slf4j.version>2.0.13</slf4j.version>
@ -57,7 +58,7 @@
<rat.version>0.10</rat.version> <!-- unused -->
<cassandra.version>4.17.0</cassandra.version>
<metrics.version>4.2.25</metrics.version>
<cassandra-all.version>3.11.17</cassandra-all.version> <!-- tools -->
<cassandra-all.version>5.0.4</cassandra-all.version> <!-- tools -->
<guava.version>33.1.0-jre</guava.version>
<caffeine.version>3.1.8</caffeine.version>
<commons-lang3.version>3.14.0</commons-lang3.version>
@ -74,7 +75,7 @@
<jackson-databind.version>2.17.2</jackson-databind.version>
<fasterxml-classmate.version>1.7.0</fasterxml-classmate.version>
<auth0-jwt.version>4.4.0</auth0-jwt.version>
<json-schema-validator.version>2.2.14</json-schema-validator.version>
<json-schema-validator.version>1.5.6</json-schema-validator.version>
<milo.version>0.6.12</milo.version>
<californium.version>3.12.1</californium.version>
<leshan.version>2.0.0-M15</leshan.version>
@ -102,7 +103,7 @@
<jts.version>1.19.0</jts.version>
<bouncycastle.version>1.78.1</bouncycastle.version>
<winsw.version>2.0.1</winsw.version>
<postgresql.driver.version>42.7.3</postgresql.driver.version>
<postgresql.driver.version>42.7.5</postgresql.driver.version>
<sonar.exclusions>org/thingsboard/server/gen/**/*,
org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/*
</sonar.exclusions>
@ -112,7 +113,7 @@
<!-- IMPORTANT: If you change the version of the kafka client, make sure to synchronize our overwritten implementation of the
org.apache.kafka.common.network.NetworkReceive class in the application module. It addresses the issue https://issues.apache.org/jira/browse/KAFKA-4090.
Here is the source to track https://github.com/apache/kafka/tree/trunk/clients/src/main/java/org/apache/kafka/common/network -->
<kafka.version>3.7.1</kafka.version>
<kafka.version>3.7.2</kafka.version>
<bucket4j.version>8.10.1</bucket4j.version>
<antlr.version>3.5.3</antlr.version>
<snakeyaml.version>2.2</snakeyaml.version>
@ -1163,6 +1164,13 @@
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<!-- Vulnerability fix - transitive dependency from Spring Boot, remove after Spring Boot upgrade -->
<dependency>
<groupId>net.minidev</groupId>
<artifactId>json-smart</artifactId>
<version>${net.minidev.json-smart}</version>
</dependency>
<!-- ...Vulnerability fix - transitive dependency from Spring Boot, remove after Spring Boot upgrade -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
@ -1183,6 +1191,18 @@
<artifactId>spring-security-oauth2-jose</artifactId>
<version>${spring-security.version}</version>
</dependency>
<!-- Vulnerability fix - transitive dependency from Spring Boot, remove after Spring Boot upgrade -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>${spring-security.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>${spring-security.version}</version>
</dependency>
<!-- ... Vulnerability fix - transitive dependency from Spring Boot, remove after Spring Boot upgrade -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
@ -1600,15 +1620,9 @@
<version>${auth0-jwt.version}</version>
</dependency>
<dependency>
<groupId>com.github.java-json-tools</groupId>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${json-schema-validator.version}</version>
<exclusions>
<exclusion>
<groupId>com.sun.mail</groupId>
<artifactId>mailapi</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.eclipse.leshan</groupId>
@ -1827,11 +1841,6 @@
<artifactId>cassandra-all</artifactId>
<version>${cassandra-all.version}</version>
</dependency>
<dependency>
<groupId>org.apache.cassandra</groupId>
<artifactId>cassandra-thrift</artifactId>
<version>${cassandra-all.version}</version>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
@ -1973,6 +1982,12 @@
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>hivemq</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>

26
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2025 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;
public interface MqttClientSettings {
int getRetransmissionMaxAttempts();
long getRetransmissionInitialDelayMillis();
double getRetransmissionJitterFactor();
}

5
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java

@ -416,4 +416,9 @@ public interface TbContext {
EventService getEventService();
AuditLogService getAuditLogService();
// Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node
MqttClientSettings getMqttClientSettings();
}

17
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java

@ -64,7 +64,7 @@ import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME;
@Slf4j
public class TbMsgDeduplicationNode implements TbNode {
public static final int TB_MSG_DEDUPLICATION_RETRY_DELAY = 10;
public static final long TB_MSG_DEDUPLICATION_RETRY_DELAY = 10L;
private TbMsgDeduplicationNodeConfiguration config;
@ -217,16 +217,17 @@ public class TbMsgDeduplicationNode implements TbNode {
}
private void enqueueForTellNextWithRetry(TbContext ctx, TbMsg msg, int retryAttempt) {
if (config.getMaxRetries() > retryAttempt) {
if (retryAttempt <= config.getMaxRetries()) {
ctx.enqueueForTellNext(msg, TbNodeConnectionType.SUCCESS,
() -> {
log.trace("[{}][{}][{}] Successfully enqueue deduplication result message!", ctx.getSelfId(), msg.getOriginator(), retryAttempt);
},
() -> log.trace("[{}][{}][{}] Successfully enqueue deduplication result message!", ctx.getSelfId(), msg.getOriginator(), retryAttempt),
throwable -> {
log.trace("[{}][{}][{}] Failed to enqueue deduplication output message due to: ", ctx.getSelfId(), msg.getOriginator(), retryAttempt, throwable);
ctx.schedule(() -> {
enqueueForTellNextWithRetry(ctx, msg, retryAttempt + 1);
}, TB_MSG_DEDUPLICATION_RETRY_DELAY, TimeUnit.SECONDS);
if (retryAttempt < config.getMaxRetries()) {
ctx.schedule(() -> enqueueForTellNextWithRetry(ctx, msg, retryAttempt + 1), TB_MSG_DEDUPLICATION_RETRY_DELAY, TimeUnit.SECONDS);
} else {
log.trace("[{}][{}] Max retries [{}] exhausted. Dropping deduplication result message [{}]",
ctx.getSelfId(), msg.getOriginator(), config.getMaxRetries(), msg.getId());
}
});
}
}

8
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java

@ -26,6 +26,7 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttConnectResult;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.RuleNode;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -126,6 +127,13 @@ public class TbMqttNode extends TbAbstractExternalNode {
}
config.setCleanSession(this.mqttNodeConfiguration.isCleanSession());
MqttClientSettings mqttClientSettings = ctx.getMqttClientSettings();
config.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(
mqttClientSettings.getRetransmissionMaxAttempts(),
mqttClientSettings.getRetransmissionInitialDelayMillis(),
mqttClientSettings.getRetransmissionJitterFactor()
));
prepareMqttClientConfig(config);
MqttClient client = getMqttClient(ctx, config);
client.setEventLoop(ctx.getSharedEventLoop());

21
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java

@ -40,6 +40,7 @@ import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttConnectResult;
import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -80,6 +81,7 @@ import static org.mockito.BDDMockito.spy;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willAnswer;
import static org.mockito.BDDMockito.willReturn;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest {
@ -106,6 +108,22 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest {
protected void setUp() {
mqttNode = spy(new TbMqttNode());
mqttNodeConfig = new TbMqttNodeConfiguration().defaultConfiguration();
lenient().when(ctxMock.getMqttClientSettings()).thenReturn(new MqttClientSettings() {
@Override
public int getRetransmissionMaxAttempts() {
return 3;
}
@Override
public long getRetransmissionInitialDelayMillis() {
return 5000L;
}
@Override
public double getRetransmissionJitterFactor() {
return 0.15;
}
});
}
@Test
@ -163,7 +181,8 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest {
SslContext actualSslContext = mqttClientConfig.getValue().getSslContext();
assertThat(actualSslContext)
.usingRecursiveComparison()
.ignoringFields("ctx", "ctxLock", "sessionContext.context.ctx", "sessionContext.context.ctxLock")
.ignoringFields("ctx", "ctxLock", "sessionContext.context.ctx", "sessionContext.context.ctxLock",
"sslContext")
.isEqualTo(SslContextBuilder.forClient().build());
}

76
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java

@ -62,11 +62,13 @@ import java.util.function.Consumer;
import java.util.stream.Stream;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ -411,6 +413,80 @@ public class TbMsgDeduplicationNodeTest extends AbstractRuleNodeUpgradeTest {
Assertions.assertEquals(msgWithLatestTsInSecondPack.getType(), actualMsg.getType());
}
@Test
public void given_maxRetriesIsZero_when_enqueueFails_then_noRetriesIsScheduled() throws TbNodeException, ExecutionException, InterruptedException {
int wantedNumberOfTellSelfInvocation = 1;
int msgCount = 1;
awaitTellSelfLatch = new CountDownLatch(wantedNumberOfTellSelfInvocation);
invokeTellSelf(wantedNumberOfTellSelfInvocation);
// Given
when(ctx.getQueueName()).thenReturn(DataConstants.MAIN_QUEUE_NAME);
config.setInterval(deduplicationInterval);
config.setStrategy(DeduplicationStrategy.FIRST);
config.setMaxPendingMsgs(msgCount);
config.setMaxRetries(0);
nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config));
node.init(ctx, nodeConfiguration);
DeviceId deviceId = new DeviceId(UUID.randomUUID());
long currentTimeMillis = System.currentTimeMillis();
doAnswer(invocation -> {
Consumer<Throwable> failureCallback = invocation.getArgument(3);
failureCallback.accept(new RuntimeException("Simulated failure"));
return null;
}).when(ctx).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS), any(), any());
TbMsg msg = createMsg(deviceId, currentTimeMillis + 1);
node.onMsg(ctx, msg);
awaitTellSelfLatch.await();
verify(ctx).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS), any(), any());
verify(ctx, never()).schedule(any(), anyLong(), any());
}
@Test
public void given_maxRetriesIsSetToOne_when_enqueueFails_then_onlyOneRetryIsScheduled() throws TbNodeException, ExecutionException, InterruptedException {
int wantedNumberOfTellSelfInvocation = 1;
int msgCount = 1;
awaitTellSelfLatch = new CountDownLatch(wantedNumberOfTellSelfInvocation);
invokeTellSelf(wantedNumberOfTellSelfInvocation);
when(ctx.getQueueName()).thenReturn(DataConstants.MAIN_QUEUE_NAME);
config.setInterval(deduplicationInterval);
config.setStrategy(DeduplicationStrategy.FIRST);
config.setMaxPendingMsgs(msgCount);
config.setMaxRetries(1);
nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config));
node.init(ctx, nodeConfiguration);
DeviceId deviceId = new DeviceId(UUID.randomUUID());
long currentTimeMillis = System.currentTimeMillis();
doAnswer(invocation -> {
Consumer<Throwable> failureCallback = invocation.getArgument(3);
failureCallback.accept(new RuntimeException("Simulated failure"));
return null;
}).when(ctx).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS), any(), any());
TbMsg msg = createMsg(deviceId, currentTimeMillis + 1);
node.onMsg(ctx, msg);
awaitTellSelfLatch.await();
ArgumentCaptor<Runnable> retryRunnableCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(ctx).schedule(retryRunnableCaptor.capture(), eq(TbMsgDeduplicationNode.TB_MSG_DEDUPLICATION_RETRY_DELAY), eq(TimeUnit.SECONDS));
retryRunnableCaptor.getValue().run();
// Verify total enqueue attempts (initial + retry)
verify(ctx, times(2)).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS), any(), any());
// No more retries scheduled after reaching maxRetries
verify(ctx).schedule(any(), eq(TbMsgDeduplicationNode.TB_MSG_DEDUPLICATION_RETRY_DELAY), eq(TimeUnit.SECONDS));
}
// Rule nodes upgrade
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of(

4
tools/pom.xml

@ -55,10 +55,6 @@
<groupId>org.apache.cassandra</groupId>
<artifactId>cassandra-all</artifactId>
</dependency>
<dependency>
<groupId>org.apache.cassandra</groupId>
<artifactId>cassandra-thrift</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>

6
tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java

@ -59,7 +59,7 @@ public class WriterBuilder {
public static CQLSSTableWriter getTsWriter(File dir) {
return CQLSSTableWriter.builder()
.inDirectory(dir)
.inDirectory(dir.getAbsolutePath())
.forTable(tsSchema)
.using("INSERT INTO thingsboard.ts_kv_cf (entity_type, entity_id, key, partition, ts, bool_v, str_v, long_v, dbl_v, json_v) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
@ -68,7 +68,7 @@ public class WriterBuilder {
public static CQLSSTableWriter getLatestWriter(File dir) {
return CQLSSTableWriter.builder()
.inDirectory(dir)
.inDirectory(dir.getAbsolutePath())
.forTable(latestSchema)
.using("INSERT INTO thingsboard.ts_kv_latest_cf (entity_type, entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
@ -77,7 +77,7 @@ public class WriterBuilder {
public static CQLSSTableWriter getPartitionWriter(File dir) {
return CQLSSTableWriter.builder()
.inDirectory(dir)
.inDirectory(dir.getAbsolutePath())
.forTable(partitionSchema)
.using("INSERT INTO thingsboard.ts_kv_partitions_cf (entity_type, entity_id, key, partition) " +
"VALUES (?, ?, ?, ?)")

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html

@ -116,7 +116,7 @@
</mat-cell>
</ng-container>
<mat-header-row class="mat-row-select"
*matHeaderRowDef="['name', 'entityType', 'target', 'type', 'key', 'actions']; sticky: true"></mat-header-row>
*matHeaderRowDef="['name', 'entityType', 'target', 'type', 'key', 'actions']"></mat-header-row>
<mat-row
*matRowDef="let argument; columns: ['name', 'entityType', 'target', 'type', 'key', 'actions']"></mat-row>
</table>

4
ui-ngx/src/app/modules/home/components/event/event-table-config.ts

@ -59,7 +59,6 @@ import { AppState } from '@core/core.state';
export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
eventTypeValue: EventType | DebugEventType;
hideClearEventAction = false;
private filterParams: FilterEventBody = {};
private filterColumns: FilterEntityColumn[] = [];
@ -95,7 +94,8 @@ export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
private cd: ChangeDetectorRef,
private store: Store<AppState>,
public testButtonLabel?: string,
private debugEventSelected?: EventEmitter<EventBody>) {
private debugEventSelected?: EventEmitter<EventBody>,
public hideClearEventAction = false) {
super();
this.loadDataOnInit = false;
this.tableTitle = '';

8
ui-ngx/src/app/modules/home/components/event/event-table.component.ts

@ -58,6 +58,9 @@ export class EventTableComponent implements OnInit, AfterViewInit, OnDestroy {
@Input()
debugEventTypes: Array<DebugEventType>;
@Input()
hideClearEventAction: boolean = false;
activeValue = false;
dirtyValue = false;
entityIdValue: EntityId;
@ -147,12 +150,13 @@ export class EventTableComponent implements OnInit, AfterViewInit, OnDestroy {
this.cd,
this.store,
this.functionTestButtonLabel,
this.debugEventSelected
this.debugEventSelected,
this.hideClearEventAction
);
}
ngAfterViewInit() {
this.isEmptyData$ = this.entitiesTable.dataSource.isEmpty().subscribe(value => this.eventTableConfig.hideClearEventAction = value);
this.isEmptyData$ = this.entitiesTable.dataSource.isEmpty().subscribe(value => this.eventTableConfig.hideClearEventAction = value || this.hideClearEventAction);
}
ngOnDestroy() {

3
ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.html

@ -116,11 +116,12 @@
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.polygon-definition</mat-label>
<input matInput formControlName="polygonsDefinition" required>
<mat-icon matSuffix class="margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.polygon-definition-hint' | translate }}">
help
</mat-icon>
<mat-hint></mat-hint>
<mat-error *ngIf="geoActionConfigForm.get('polygonsDefinition').hasError('required')">
{{ 'rule-node-config.polygon-definition-required' | translate }}
</mat-error>

2
ui-ngx/src/app/modules/home/components/rule-node/action/math-function-config.component.html

@ -77,7 +77,7 @@
<mat-form-field floatLabel="always" class="mat-block flex-1">
<mat-label translate>rule-node-config.key-field-input</mat-label>
<input matInput formControlName="key" required/>
<mat-icon class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon" matSuffix
<mat-icon class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon" matSuffix
color="primary"
matTooltip="{{ 'rule-node-config.math-templatization-tooltip' | translate }}">help</mat-icon>
<mat-error *ngIf="mathFunctionConfigForm.get('result.key').hasError('required')" translate>

3
ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html

@ -19,9 +19,10 @@
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.custom-table-name</mat-label>
<input required matInput formControlName="tableName">
<mat-icon class="help-icon margin-8 cursor-pointer"
<mat-icon class="help-icon m-2 cursor-pointer"
aria-hidden="false"
aria-label="help-icon"
color="primary"
matSuffix
matTooltip="{{ 'rule-node-config.custom-table-hint' | translate }}">
help

3
ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html

@ -76,9 +76,10 @@
requiredText="{{ 'rule-node-config.default-ttl-required' | translate }}"
minErrorText="{{ 'rule-node-config.min-default-ttl-message' | translate }}"
formControlName="defaultTTL">
<mat-icon class="mr-2 cursor-pointer"
<mat-icon class="help-icon mr-2 cursor-pointer"
aria-hidden="false"
aria-label="help-icon"
color="primary"
matSuffix
matTooltip="{{ 'rule-node-config.default-ttl-hint' | translate }}">
help

2
ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html

@ -64,7 +64,7 @@
*ngIf="argumentControl.get('type').value && argumentControl.get('type').value !== ArgumentType.CONSTANT">
<mat-label translate>rule-node-config.argument-key-field-input</mat-label>
<input [formControl]="argumentControl.get('key')" matInput required/>
<mat-icon class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon" matSuffix
<mat-icon class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon" matSuffix
color="primary"
matTooltip="{{ 'rule-node-config.math-templatization-tooltip' | translate }}">
help

2
ui-ngx/src/app/modules/home/components/rule-node/common/device-relations-query-config.component.html

@ -60,7 +60,7 @@
[emptyInputPlaceholder]="'rule-node-config.add-device-profile' | translate"
[filledInputPlaceholder]="'rule-node-config.add-device-profile' | translate"
formControlName="deviceTypes">
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon" color="primary"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon" color="primary"
matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.device-profile' | translate } }}">help</mat-icon>
</tb-entity-subtype-list>
</section>

2
ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html

@ -62,7 +62,7 @@
</div>
</mat-option>
</mat-autocomplete>
<mat-icon class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon" matSuffix
<mat-icon class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon" matSuffix
color="primary"
matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.message-type' | translate } }}">help</mat-icon>
<mat-error *ngIf="chipList.errorState">

2
ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html

@ -53,7 +53,7 @@
</div>
<ng-template #helpIcon>
<mat-icon class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.field-name' | translate } }}">help</mat-icon>
</ng-template>

2
ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html

@ -22,7 +22,7 @@
[placeholder]="'rule-node-config.add-detail' | translate"
[requiredText]="'rule-node-config.entity-details-list-empty' | translate"
formControlName="detailsList">
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.detail' | translate } }}">
help

4
ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html

@ -85,7 +85,7 @@
<mat-error *ngIf="lambdaConfigForm.get('connectionTimeout').hasError('min')">
{{ 'rule-node-config.connection-timeout-min' | translate }}
</mat-error>
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.connection-timeout-hint' | translate }}">help</mat-icon>
</mat-form-field>
@ -98,7 +98,7 @@
<mat-error *ngIf="lambdaConfigForm.get('requestTimeout').hasError('min')">
{{ 'rule-node-config.request-timeout-min' | translate }}
</mat-error>
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.request-timeout-hint' | translate }}">help</mat-icon>
</mat-form-field>

4
ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html

@ -26,14 +26,14 @@
[label]="'rule-node-config.data-keys' | translate"
[placeholder]="'rule-node-config.add-message-field' | translate"
formControlName="messageNames">
<mat-icon matSuffix color="primary" class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix color="primary" class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.field-name' | translate } }}">help</mat-icon>
</tb-string-items-list>
<tb-string-items-list editable subscriptSizing="dynamic"
[label]="'rule-node-config.metadata-keys' | translate"
[placeholder]="'rule-node-config.add-metadata-field' | translate"
formControlName="metadataNames">
<mat-icon matSuffix color="primary" class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix color="primary" class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.field-name' | translate } }}">help</mat-icon>
</tb-string-items-list>
<div tb-hint-tooltip-icon="{{ 'rule-node-config.check-all-keys-tooltip' | translate }}"

2
ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html

@ -24,7 +24,7 @@
[filledInputPlaceholder]="'rule-node-config.add-entity-type' | translate"
[label]="'rule-node-config.select-entity-types' | translate"
required>
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.entity-type' | translate } }}">help</mat-icon>
</tb-entity-type-list>

2
ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html

@ -27,7 +27,7 @@
[placeholder]="'rule-node-config.add-key' | translate"
[requiredText]="'rule-node-config.key-val.at-least-one-key-error' | translate"
formControlName="keys">
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.use-regular-expression-hint' | translate }}">
help

6
ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.html

@ -25,7 +25,7 @@
<mat-error *ngIf="deduplicationConfigForm.get('interval').hasError('min')">
{{'rule-node-config.interval-min-error' | translate}}
</mat-error>
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.interval-hint' | translate }}">help</mat-icon>
</mat-form-field>
@ -73,7 +73,7 @@
<mat-error *ngIf="deduplicationConfigForm.get('maxPendingMsgs').hasError('min')">
{{'rule-node-config.max-pending-msgs-min-error' | translate}}
</mat-error>
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.max-pending-msgs-hint' | translate }}">help</mat-icon>
</mat-form-field>
@ -89,7 +89,7 @@
<mat-error *ngIf="deduplicationConfigForm.get('maxRetries').hasError('min')">
{{'rule-node-config.max-retries-min-error' | translate}}
</mat-error>
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.max-retries-hint' | translate }}">help</mat-icon>
</mat-form-field>

2
ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html

@ -26,7 +26,7 @@
[placeholder]="'rule-node-config.add-key' | translate"
[requiredText]="'rule-node-config.key-val.at-least-one-key-error' | translate"
formControlName="keys">
<mat-icon matSuffix class="help-icon margin-8 cursor-pointer" aria-hidden="false" aria-label="help-icon"
<mat-icon matSuffix class="help-icon m-2 cursor-pointer" aria-hidden="false" aria-label="help-icon"
color="primary"
matTooltip="{{ 'rule-node-config.use-regular-expression-delete-hint' | translate }}">
help

9
ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts

@ -43,7 +43,6 @@ import {
isDefined,
isDefinedAndNotNull,
isNotEmptyStr,
isNumber,
isObject,
isUndefined
} from '@core/utils';
@ -77,6 +76,8 @@ import {
getHeaderTitle,
getRowStyleInfo,
getTableCellButtonActions,
isValidPageStepCount,
isValidPageStepIncrement,
noDataMessage,
prepareTableCellButtonActions,
RowStyleInfo,
@ -392,10 +393,10 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'alarm, ctx');
const pageSize = this.settings.defaultPageSize;
let pageStepIncrement = this.settings.pageStepIncrement;
let pageStepCount = this.settings.pageStepCount;
let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null;
let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
if (Number.isInteger(pageSize) && pageSize > 0) {
this.defaultPageSize = pageSize;
}

9
ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart.ts

@ -122,17 +122,10 @@ export class TbPieChart extends TbLatestChart<PieChartSettings> {
seriesData.push(
{id: dataItem.id, value: dataItem.value, name: dataItem.dataKey.label, itemStyle: {color: dataItem.dataKey.color}}
);
if (this.settings.doughnut && enabledDataItems.length > 1) {
seriesData.push({
value: 0, name: '', itemStyle: {color: 'transparent'}, emphasis: {disabled: true}
});
}
}
}
if (this.settings.doughnut) {
for (let i = 1; i < seriesData.length; i += 2) {
seriesData[i].value = this.total / 100;
}
this.latestChartOption.series[0].padAngle = enabledDataItems.length > 1 ? 2 : 0;
}
this.latestChartOption.series[0].data = seriesData;
}

10
ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts

@ -42,7 +42,7 @@ import {
import { IWidgetSubscription } from '@core/api/widget-api.models';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { deepClone, hashCode, isDefined, isDefinedAndNotNull, isNumber, isObject, isUndefined } from '@core/utils';
import { deepClone, hashCode, isDefined, isDefinedAndNotNull, isObject, isUndefined } from '@core/utils';
import cssjs from '@core/css/css';
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
@ -75,6 +75,8 @@ import {
getHeaderTitle,
getRowStyleInfo,
getTableCellButtonActions,
isValidPageStepCount,
isValidPageStepIncrement,
noDataMessage,
prepareTableCellButtonActions,
RowStyleInfo,
@ -311,10 +313,10 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'entity, ctx');
const pageSize = this.settings.defaultPageSize;
let pageStepIncrement = this.settings.pageStepIncrement;
let pageStepCount = this.settings.pageStepCount;
let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null;
let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
if (Number.isInteger(pageSize) && pageSize > 0) {
this.defaultPageSize = pageSize;
}

10
ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts

@ -36,6 +36,8 @@ import { BehaviorSubject, merge, Observable, of, ReplaySubject, Subject, throwEr
import { catchError, map, tap } from 'rxjs/operators';
import {
constructTableCssString,
isValidPageStepCount,
isValidPageStepIncrement,
noDataMessage,
TableCellButtonActionDescriptor,
TableWidgetSettings
@ -43,7 +45,7 @@ import {
import cssjs from '@core/css/css';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { hashCode, isDefined, isDefinedAndNotNull, isNumber, parseHttpErrorMessage } from '@core/utils';
import { hashCode, isDefined, isDefinedAndNotNull, parseHttpErrorMessage } from '@core/utils';
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import {
@ -207,10 +209,10 @@ export class PersistentTableComponent extends PageComponent implements OnInit, O
this.displayedColumns = [...this.displayTableColumns];
const pageSize = this.settings.defaultPageSize;
let pageStepIncrement = this.settings.pageStepIncrement;
let pageStepCount = this.settings.pageStepCount;
let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null;
let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
if (Number.isInteger(pageSize) && pageSize > 0) {
this.defaultPageSize = pageSize;
}

168
ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts

@ -83,6 +83,10 @@ export interface ScadaSymbolApi {
cssAnimation: (element: Element) => ScadaSymbolAnimation | undefined;
resetCssAnimation: (element: Element) => void;
finishCssAnimation: (element: Element) => void;
connectorAnimation:(element: Element) => ConnectorScadaSymbolAnimation | undefined;
connectorAnimate:(element: Element, path: string, reversedPath: string) => ConnectorScadaSymbolAnimation;
resetConnectorAnimation: (element: Element) => void;
finishConnectorAnimation: (element: Element) => void;
disable: (element: Element | Element[]) => void;
enable: (element: Element | Element[]) => void;
callAction: (event: Event, behaviorId: string, value?: any, observer?: Partial<Observer<void>>) => void;
@ -186,6 +190,8 @@ const tbNamespaceRegex = /<svg.*(xmlns:tb="https:\/\/thingsboard.io\/svg").*>/gm
const tbTagRegex = /tb:tag="([^"]*)"/gms;
const syncTime = Date.now();
const generateElementId = () => {
const id = guid();
const firstChar = id.charAt(0);
@ -485,6 +491,7 @@ export class ScadaSymbolObject {
private settings: ScadaSymbolObjectSettings;
private context: ScadaSymbolContext;
private cssAnimations: CssScadaSymbolAnimations;
private connectorAnimations: ScadaSymbolFlowConnectorAnimations;
private svgShape: Svg;
private box: Box;
@ -604,6 +611,7 @@ export class ScadaSymbolObject {
private init() {
this.cssAnimations = new CssScadaSymbolAnimations(this.svgShape, this.raf);
this.connectorAnimations = new ScadaSymbolFlowConnectorAnimations();
this.context = {
api: {
generateElementId: () => generateElementId(),
@ -615,6 +623,10 @@ export class ScadaSymbolObject {
cssAnimation: this.cssAnimation.bind(this),
resetCssAnimation: this.resetCssAnimation.bind(this),
finishCssAnimation: this.finishCssAnimation.bind(this),
connectorAnimation: this.connectorAnimation.bind(this),
connectorAnimate: this.connectorAnimate.bind(this),
resetConnectorAnimation: this.resetConnectorAnimation.bind(this),
finishConnectorAnimation: this.finishConnectorAnimation.bind(this),
disable: this.disableElement.bind(this),
enable: this.enableElement.bind(this),
callAction: this.callAction.bind(this),
@ -959,6 +971,22 @@ export class ScadaSymbolObject {
this.cssAnimations.finishAnimation(element);
}
private connectorAnimate(element: Element, path: string, reversedPath: string): ConnectorScadaSymbolAnimation {
return this.connectorAnimations.animate(element, path, reversedPath);
}
private connectorAnimation(element: Element): ConnectorScadaSymbolAnimation | undefined {
return this.connectorAnimations.animation(element);
}
private resetConnectorAnimation(element: Element) {
this.connectorAnimations.resetAnimation(element);
}
private finishConnectorAnimation(element: Element) {
this.connectorAnimations.finishAnimation(element);
}
private disableElement(e: Element | Element[]) {
this.elements(e).forEach(element => {
element.attr({'pointer-events': 'none'});
@ -1108,6 +1136,20 @@ interface ScadaSymbolAnimation {
}
const scadaSymbolConnectorFlowAnimationId = 'scadaSymbolConnectorFlowAnimation';
type StrokeLineCap = 'butt' | 'round '| 'square';
interface ConnectorScadaSymbolAnimation {
play(): void;
stop(): void;
finish(): void;
flowAppearance(width: number, color: string, lineCap: StrokeLineCap, dashWidth: number, dashGap: number): ConnectorScadaSymbolAnimation;
duration(speed: number): ConnectorScadaSymbolAnimation;
direction(direction: boolean): ConnectorScadaSymbolAnimation;
}
class CssScadaSymbolAnimations {
constructor(private svgShape: Svg,
private raf: RafService) {}
@ -1159,6 +1201,132 @@ class CssScadaSymbolAnimations {
}
}
class ScadaSymbolFlowConnectorAnimations {
constructor() {}
public animate(element: Element, path = '', reversedPath = ''): ConnectorScadaSymbolAnimation {
this.checkOldAnimation(element);
return this.setupAnimation(element, this.createAnimation(element, path, reversedPath));
}
public animation(element: Element): ConnectorScadaSymbolAnimation | undefined {
return element.remember(scadaSymbolConnectorFlowAnimationId);
}
public resetAnimation(element: Element) {
const animation: ConnectorScadaSymbolAnimation = element.remember(scadaSymbolConnectorFlowAnimationId);
if (animation) {
animation.stop();
element.remember(scadaSymbolConnectorFlowAnimationId, null);
}
}
public finishAnimation(element: Element) {
const animation: ConnectorScadaSymbolAnimation = element.remember(scadaSymbolConnectorFlowAnimationId);
if (animation) {
animation.finish();
element.remember(scadaSymbolConnectorFlowAnimationId, null);
}
}
private setupAnimation(element: Element, animation: ConnectorScadaSymbolAnimation): ConnectorScadaSymbolAnimation {
element.remember(scadaSymbolConnectorFlowAnimationId, animation);
return animation;
}
private checkOldAnimation(element: Element) {
const previousAnimation: ConnectorScadaSymbolAnimation = element.remember(scadaSymbolConnectorFlowAnimationId);
if (previousAnimation) {
previousAnimation.finish();
}
}
private createAnimation(element: Element, path: string, reversedPath: string): ConnectorScadaSymbolAnimation {
return new FlowConnectorAnimation(element, path, reversedPath);
}
}
class FlowConnectorAnimation implements ConnectorScadaSymbolAnimation {
private readonly _path: string;
private readonly _reversedPath: string;
private readonly _animation: Element;
private _duration: number = 1;
private _lineColor: string = '#C8DFF7';
private _lineWidth: number = 4;
private _strokeLineCap: StrokeLineCap = 'butt';
private _dashWidth: number = 10;
private _dashGap: number = 10;
private _direction: boolean = true;
constructor(private element: Element,
path: string,
pathReversed: string) {
this._path = path;
this._reversedPath = pathReversed;
const dashArray = `${this._dashWidth} ${this._dashGap}`;
const values = `${this._dashWidth + this._dashGap};0`;
this._animation = SVG(
`<path d="${this._path}" stroke-dasharray="${dashArray}" stroke-linecap="${this._strokeLineCap}" fill="none" stroke="${this._lineColor}" stroke-width="${this._lineWidth}">` +
`<animate attributeName="stroke-dashoffset" values="${values}" dur="${this._duration}s" begin="indefinite" calcMode="linear" repeatCount="indefinite"></animate></path>`
);
}
public play() {
if (!this.element.node.childElementCount) {
this.element.add(this._animation);
}
const animateElement = this.element.node.getElementsByTagName('animate')[0];
const offset = ((Date.now() - syncTime) % 1000) * -1;
(animateElement as SVGAnimationElement).beginElementAt(offset);
}
public stop() {
const animateElement = this.element.node.getElementsByTagName('animate')[0];
(animateElement as SVGAnimationElement)?.endElement();
}
public finish() {
this.element.findOne('path')?.remove();
}
public flowAppearance(width: number, color: string, linecap: StrokeLineCap, dashWidth: number, dashGap: number): this {
const totalLength = (this._animation.node as SVGPathElement).getTotalLength();
let offset = 0;
if ((totalLength % 100) !== 0) {
const clientWidth = totalLength < 100 ? 100 : this.element.node.ownerSVGElement.clientWidth;
const clientWidthDash = clientWidth / (dashWidth + dashGap);
const totalLengthDash = totalLength / clientWidthDash;
offset = ((dashWidth + dashGap) - totalLengthDash) / 2;
}
this._lineColor = color;
this._lineWidth = width;
this._strokeLineCap = linecap;
this._dashWidth = dashWidth - offset;
this._dashGap = dashGap - offset;
const dashArray = `${this._dashWidth} ${this._dashGap}`;
const values = `${this._dashWidth + (this._dashGap || this._dashWidth)};0`;
this._animation.stroke({width, color, linecap, dasharray: dashArray});
this._animation.findOne('animate').attr('values', values);
return this;
}
public duration(speed: number): this {
this._duration = speed;
this._animation.findOne('animate').attr('dur', `${speed}s`);
return this;
}
public direction(direction: boolean): this {
this._direction = direction;
this._animation.attr('d', direction ? this._path : this._reversedPath);
return this;
}
}
interface ScadaSymbolAnimationKeyframe {
stop: string;
style: any;

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save