diff --git a/README.md b/README.md
index bffd87ffa5..6267cc3a7a 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,150 @@
-# ThingsBoard
-[](https://builds.thingsboard.io/viewType.html?buildTypeId=ThingsBoard_Build&guest=1)
+
+
+
+
+# Open-source IoT platform for data collection, processing, visualization, and device management.
+
+
+
+
+
+π‘ [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) β’ π [Website](https://thingsboard.io/) β’ π [Documentation](https://thingsboard.io/docs/) β’ π [Blog](https://thingsboard.io/blog/) β’ βΆοΈ [Live demo](https://demo.thingsboard.io/signup) β’ π [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all)
+
+
+
+## π Installation options
+
+* Install ThingsBoard [On-premise](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise)
+* Try [ThingsBoard Cloud](https://thingsboard.io/installations/)
+* or [Use our Live demo](https://demo.thingsboard.io/signup)
+
+## π‘ Getting started with ThingsBoard
+
+Check out our [Getting Started guide](https://thingsboard.io/docs/getting-started-guides/helloworld/) or [watch the video](https://www.youtube.com/watch?v=80L0ubQLXsc) to learn the basics of ThingsBoard and create your first dashboard! You will learn to:
+
+* Connect devices to ThingsBoard
+* Push data from devices to ThingsBoard
+* Build real-time dashboards
+* Create a Customer and assign the dashboard with them.
+* Define thresholds and trigger alarms
+* Set up notifications via email, SMS, mobile apps, or integrate with third-party services.
+
+## β¨ Features
+
+
+
+
+
+
+
+
Provision and manage devices and assets
+
+
+
Provision, monitor and control your IoT entities in secure way using rich server-side APIs. Define relations between your devices, assets, customers or any other entities.
+
+
+
+
+
+
+
+
+
+
Collect and visualize your data
+
+
+
Collect and store telemetry data in scalable and fault-tolerant way. Visualize your data with built-in or custom widgets and flexible dashboards. Share dashboards with your customers.
+
+
+
+
+
+
+
+
+
+
+
+
SCADA Dashboards
+
+
+
Monitor and control your industrial processes in real time with SCADA. Use SCADA symbols on dashboards to create and manage any workflow, offering full flexibility to design and oversee operations according to your requirements.
+
+
+
+
+
+
+
+
+
+
Process and React
+
+
+
Define data processing rule chains. Transform and normalize your device data. Raise alarms on incoming telemetry events, attribute updates, device inactivity and user actions.
+
+
+
+
+
+
+
+
+
+## βοΈ Powerful IoT Rule Engine
+
+ThingsBoard allows you to create complex [Rule Chains](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) to process data from your devices and match your application specific use cases.
+
+[](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
+
+
+
+[**Read more about Rule Engine β**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
+
+
+
+## π¦ Real-Time IoT Dashboards
+
+ThingsBoard is a scalable, user-friendly, and device-agnostic IoT platform that speeds up time-to-market with powerful built-in solution templates. It enables data collection and analysis from any devices, saving resources on routine tasks and letting you focus on your solutionβs unique aspects. See more our Use Cases [here](https://thingsboard.io/iot-use-cases/).
+
+[**Smart energy**](https://thingsboard.io/use-cases/smart-energy/)
+
+[](https://thingsboard.io/use-cases/smart-energy/)
+
+[**SCADA swimming pool**](https://thingsboard.io/use-cases/scada/)
+
+[](https://thingsboard.io/use-cases/scada/)
+
+[**Fleet tracking**](https://thingsboard.io/use-cases/fleet-tracking/)
+
+[](https://thingsboard.io/use-cases/fleet-tracking/)
+
+[**Smart farming**](https://thingsboard.io/use-cases/smart-farming/)
+
+[](https://thingsboard.io/use-cases/smart-farming/)
-ThingsBoard is an open-source IoT platform for data collection, processing, visualization, and device management.
-
-
-
-
-## Documentation
-
-ThingsBoard documentation is hosted on [thingsboard.io](https://thingsboard.io/docs).
-
-## IoT use cases
-
-[**Smart energy**](https://thingsboard.io/smart-energy/)
-[](https://thingsboard.io/smart-energy/)
-
-[**SCADA Swimming pool**](https://thingsboard.io/use-cases/scada/)
-[](https://thingsboard.io/use-cases/scada/)
-
-[**Fleet tracking**](https://thingsboard.io/fleet-tracking/)
-[](https://thingsboard.io/fleet-tracking/)
-
-[**Smart farming**](https://thingsboard.io/smart-farming/)
-[](https://thingsboard.io/smart-farming/)
+[**Smart metering**](https://thingsboard.io/smart-metering/)
-[**IoT Rule Engine**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
-[](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
+[](https://thingsboard.io/smart-metering/)
-[**Smart metering**](https://thingsboard.io/smart-metering/)
-[](https://thingsboard.io/smart-metering/)
+
-## Getting Started
+[**Check more of our use cases β**](https://thingsboard.io/iot-use-cases/)
-Collect and Visualize your IoT data in minutes by following this [guide](https://thingsboard.io/docs/getting-started-guides/helloworld/).
+
-## Support
+## π«Ά Support
- - [Stackoverflow](http://stackoverflow.com/questions/tagged/thingsboard)
+To get support, please visit our [GitHub issues page](https://github.com/thingsboard/thingsboard/issues)
-## Licenses
+## π Licenses
-This project is released under [Apache 2.0 License](./LICENSE).
+This project is released under [Apache 2.0 License](./LICENSE)
diff --git a/application/pom.xml b/application/pom.xml
index dba7d27c1b..4cbd9c3b64 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -419,6 +419,10 @@
+
+ dev.langchain4j
+ langchain4j-ollama
+
diff --git a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json
index a30d6ee76f..2701abcb84 100644
--- a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json
+++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json
@@ -13,6 +13,7 @@
"home_page_widgets.quick_links",
"home_page_widgets.documentation_links",
"home_page_widgets.dashboards",
- "home_page_widgets.usage_info"
+ "home_page_widgets.usage_info",
+ "api_usage"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/alarms_table.json b/application/src/main/data/json/system/widget_types/alarms_table.json
index 4f86957f1c..446129972b 100644
--- a/application/src/main/data/json/system/widget_types/alarms_table.json
+++ b/application/src/main/data/json/system/widget_types/alarms_table.json
@@ -12,18 +12,12 @@
"templateHtml": "\n ",
"templateCss": "",
"controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.alarmsTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.alarmsTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n supportsUnitConversion: true\n };\n};\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
- "settingsSchema": "",
- "dataKeySettingsSchema": "",
"settingsDirective": "tb-alarms-table-widget-settings",
"dataKeySettingsDirective": "tb-alarms-table-key-settings",
"hasBasicMode": true,
"basicModeDirective": "tb-alarms-table-basic-config",
- "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSelection\":true,\"enableSearch\":true,\"displayDetails\":true,\"allowAcknowledgment\":true,\"allowClear\":true,\"allowAssign\":true,\"displayActivity\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"-createdTime\",\"enableSelectColumnDisplay\":true,\"enableStickyAction\":false,\"enableFilter\":true,\"entitiesTitle\":null,\"alarmsTitle\":\"Alarms\"},\"title\":\"Alarms table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"alarmSource\":{\"type\":\"function\",\"dataKeys\":[{\"name\":\"createdTime\",\"type\":\"alarm\",\"label\":\"Created time\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.021092237451093787},{\"name\":\"originator\",\"type\":\"alarm\",\"label\":\"Originator\",\"color\":\"#4caf50\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.2780007688856758},{\"name\":\"type\",\"type\":\"alarm\",\"label\":\"Type\",\"color\":\"#f44336\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.7323586880398418},{\"name\":\"severity\",\"type\":\"alarm\",\"label\":\"Severity\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":false,\"useCellContentFunction\":false},\"_hash\":0.09927019860088193},{\"name\":\"status\",\"type\":\"alarm\",\"label\":\"Status\",\"color\":\"#607d8b\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6588418951443418},{\"name\":\"assignee\",\"type\":\"alarm\",\"label\":\"Assignee\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.5008441077416634}],\"entityAliasId\":null,\"name\":\"alarms\"},\"alarmSearchStatus\":\"ANY\",\"alarmsPollingInterval\":5,\"showTitleIcon\":false,\"titleIcon\":\"warning\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"displayTimewindow\":true,\"actions\":{},\"alarmStatusList\":[],\"alarmSeverityList\":[],\"alarmTypeList\":[],\"searchPropagatedAlarms\":false,\"configMode\":\"basic\",\"alarmFilterConfig\":null}"
+ "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSelection\":true,\"enableSearch\":true,\"displayDetails\":true,\"allowAcknowledgment\":true,\"allowClear\":true,\"allowAssign\":true,\"displayActivity\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"-createdTime\",\"enableSelectColumnDisplay\":true,\"enableStickyAction\":false,\"enableFilter\":true,\"entitiesTitle\":null,\"alarmsTitle\":\"Alarms\"},\"title\":\"Alarms table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"alarmSource\":{\"type\":\"function\",\"dataKeys\":[{\"name\":\"createdTime\",\"type\":\"alarm\",\"label\":\"Created time\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.021092237451093787},{\"name\":\"originatorDisplayName\",\"type\":\"alarm\",\"label\":\"Originator\",\"color\":\"#4caf50\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.2780007688856758},{\"name\":\"type\",\"type\":\"alarm\",\"label\":\"Type\",\"color\":\"#f44336\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.7323586880398418},{\"name\":\"severity\",\"type\":\"alarm\",\"label\":\"Severity\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":false,\"useCellContentFunction\":false},\"_hash\":0.09927019860088193},{\"name\":\"status\",\"type\":\"alarm\",\"label\":\"Status\",\"color\":\"#607d8b\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6588418951443418},{\"name\":\"assignee\",\"type\":\"alarm\",\"label\":\"Assignee\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.5008441077416634}],\"entityAliasId\":null,\"name\":\"alarms\"},\"alarmSearchStatus\":\"ANY\",\"alarmsPollingInterval\":5,\"showTitleIcon\":false,\"titleIcon\":\"warning\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"displayTimewindow\":true,\"actions\":{},\"alarmStatusList\":[],\"alarmSeverityList\":[],\"alarmTypeList\":[],\"searchPropagatedAlarms\":false,\"configMode\":\"basic\",\"alarmFilterConfig\":null}"
},
- "tags": [
- "alert",
- "alerts"
- ],
"resources": [
{
"link": "/api/images/system/alarms_table_system_widget_image.png",
@@ -36,5 +30,10 @@
"data": "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",
"public": true
}
+ ],
+ "scada": false,
+ "tags": [
+ "alert",
+ "alerts"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/api_usage.json b/application/src/main/data/json/system/widget_types/api_usage.json
new file mode 100644
index 0000000000..9d6f2bc464
--- /dev/null
+++ b/application/src/main/data/json/system/widget_types/api_usage.json
@@ -0,0 +1,35 @@
+{
+ "fqn": "api_usage",
+ "name": "API Usage",
+ "deprecated": false,
+ "image": "tb-image;/api/images/system/api-usage-widget.png",
+ "description": null,
+ "descriptor": {
+ "type": "latest",
+ "sizeX": 7.5,
+ "sizeY": 3,
+ "resources": [],
+ "templateHtml": "\n ",
+ "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}",
+ "controllerScript": "self.onInit = function() {\n self.ctx.$scope.apiUsageWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n datasourcesOptional: true,\n previewWidth: '400px',\n previewHeight: '300px'\n };\n}",
+ "settingsForm": [],
+ "dataKeySettingsForm": [],
+ "settingsDirective": "tb-api-usage-widget-settings",
+ "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0\",\"settings\":{},\"title\":\"API usage\",\"decimals\":null,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\".tb-widget-header {\\n height: 48px;\\n align-items: center !important;\\n padding: 5px 10px 0 10px;\\n}\",\"titleStyle\":{},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"actions\":{\"headerButton\":[{\"name\":\"Go back\",\"buttonType\":\"stroked\",\"showIcon\":true,\"icon\":\"undo\",\"buttonColor\":\"#305680\",\"buttonBorderColor\":\"#0000001F\",\"customButtonStyle\":{\"padding\":\"0 16px\"},\"useShowWidgetActionFunction\":true,\"showWidgetActionFunction\":\"return widgetContext.stateController.getStateId() !== widgetContext.settings.targetDashboardState && widgetContext.settings.targetDashboardState;\",\"type\":\"custom\",\"customFunction\":\"const state = widgetContext.settings.targetDashboardState?.length ? widgetContext.settings.targetDashboardState : 'default';\\nwidgetContext.stateController.updateState(state, widgetContext.stateController.getStateParams(), false);\",\"openInSeparateDialog\":false,\"openInPopover\":false,\"id\":\"1ea1cca6-47d1-3539-d051-9535129fb12b\"}]},\"titleFont\":{\"size\":16,\"sizeUnit\":\"px\",\"family\":null,\"weight\":\"500\",\"style\":null,\"lineHeight\":\"21px\"},\"borderRadius\":\"4px\"}"
+ },
+ "resources": [
+ {
+ "link": "/api/images/system/api-usage-widget.png",
+ "title": "\"API Usage\" system widget image",
+ "type": "IMAGE",
+ "subType": "IMAGE",
+ "fileName": "api-usage-widget.png",
+ "publicResourceKey": "1TAYhxLWInZC20Xrn8PgbNbQs6fX8Pir",
+ "mediaType": "image/png",
+ "data": "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",
+ "public": true
+ }
+ ],
+ "scada": false,
+ "tags": null
+}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/entities_table.json b/application/src/main/data/json/system/widget_types/entities_table.json
index 01034a1d1d..f58bd3f0ce 100644
--- a/application/src/main/data/json/system/widget_types/entities_table.json
+++ b/application/src/main/data/json/system/widget_types/entities_table.json
@@ -11,19 +11,13 @@
"resources": [],
"templateHtml": "\n ",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true,\n supportsUnitConversion: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'name', type: 'entityField' }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
- "settingsSchema": "",
- "dataKeySettingsSchema": "",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true,\n supportsUnitConversion: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'displayName', type: 'entityField' }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
"settingsDirective": "tb-entities-table-widget-settings",
"dataKeySettingsDirective": "tb-entities-table-key-settings",
"hasBasicMode": true,
"basicModeDirective": "tb-entities-table-basic-config",
"defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"reserveSpaceForHiddenAction\":\"true\",\"displayEntityName\":false,\"displayEntityLabel\":false,\"displayEntityType\":false,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"name\",\"useRowStyleFunction\":false,\"entitiesTitle\":\"Entities\"},\"title\":\"Entities table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Entity name\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return 'Simulated';\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Entity type\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.782057645776538,\"funcBody\":\"return 'Device';\",\"decimals\":null,\"aggregationType\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.904797781901171,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\",\"decimals\":0},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.1961430898042078,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\",\"decimals\":0},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.7678057538205878,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\",\"decimals\":2}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"displayTimewindow\":false,\"configMode\":\"basic\",\"actions\":{},\"showTitleIcon\":false,\"titleIcon\":\"list\",\"iconColor\":null}"
},
- "tags": [
- "administration",
- "management"
- ],
"resources": [
{
"link": "/api/images/system/entities_table_system_widget_image.png",
@@ -36,5 +30,10 @@
"data": "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",
"public": true
}
+ ],
+ "scada": false,
+ "tags": [
+ "administration",
+ "management"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/markdown_html_card.json b/application/src/main/data/json/system/widget_types/markdown_html_card.json
index 64a847952a..b66e3ee88e 100644
--- a/application/src/main/data/json/system/widget_types/markdown_html_card.json
+++ b/application/src/main/data/json/system/widget_types/markdown_html_card.json
@@ -11,16 +11,10 @@
"resources": [],
"templateHtml": "\n ",
"templateCss": "#container tb-markdown-widget {\n height: 100%;\n display: block;\n}\n\n#container tb-markdown-widget .tb-markdown-view {\n height: 100%;\n overflow: auto;\n}\n",
- "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.markdownWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true,\n datasourcesOptional: true,\n hasDataPageLink: true\n };\n}\n\nself.onDestroy = function() {\n}\n\n",
- "settingsSchema": "",
- "dataKeySettingsSchema": "",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.markdownWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true,\n datasourcesOptional: true,\n hasDataPageLink: true,\n hideDataSettings: true\n };\n}\n\nself.onDestroy = function() {\n}\n\n",
"settingsDirective": "tb-markdown-widget-settings",
- "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"markdownTextPattern\":\"### Markdown/HTML card\\n - **Current entity**: ${entityName}.\\n - **Current value**: ${Random}.\",\"markdownTextFunction\":\"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\",\"useMarkdownTextFunction\":false},\"title\":\"Markdown/HTML Card\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}"
+ "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"const baseTemp = 20;\\nconst dailySwing = 10;\\nconst hourlyVariation = Math.sin((time % 24) * Math.PI / 12) * dailySwing;\\nconst randomness = (Math.random() - 0.5) * 2;\\nconst smoothingFactor = 0.8;\\nreturn (prevValue * smoothingFactor) + ((baseTemp + hourlyVariation + randomness) * (1 - smoothingFactor));\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"useMarkdownTextFunction\":false,\"markdownTextPattern\":\"### Markdown/HTML card\\n - **Current entity**: ${entityName}.\\n - **Current value**: ${Temperature}.\",\"markdownTextFunction\":\"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\",\"applyDefaultMarkdownStyle\":true,\"markdownCss\":\"\"},\"title\":\"Markdown/HTML Card\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false,\"useDashboardTimewindow\":true,\"displayTimewindow\":true}"
},
- "tags": [
- "web",
- "markup"
- ],
"resources": [
{
"link": "/api/images/system/markdown_html_card_system_widget_image.png",
@@ -33,5 +27,10 @@
"data": "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",
"public": true
}
+ ],
+ "scada": false,
+ "tags": [
+ "web",
+ "markup"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql
index 016e786776..0add4c0545 100644
--- a/application/src/main/data/upgrade/basic/schema_update.sql
+++ b/application/src/main/data/upgrade/basic/schema_update.sql
@@ -14,3 +14,35 @@
-- limitations under the License.
--
+-- UPDATE TENANT PROFILE CONFIGURATION START
+
+UPDATE tenant_profile
+SET profile_data = jsonb_set(
+ profile_data,
+ '{configuration}',
+ (profile_data -> 'configuration')
+ || jsonb_strip_nulls(
+ jsonb_build_object(
+ 'minAllowedScheduledUpdateIntervalInSecForCF',
+ CASE
+ WHEN (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF'
+ THEN NULL
+ ELSE to_jsonb(60)
+ END,
+ 'maxRelationLevelPerCfArgument',
+ CASE
+ WHEN (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument'
+ THEN NULL
+ ELSE to_jsonb(10)
+ END
+ )
+ ),
+ false
+ )
+WHERE NOT (
+ (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF'
+ AND
+ (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument'
+ );
+
+-- UPDATE TENANT PROFILE CONFIGURATION END
diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
index ea46ce86eb..b23015a1fe 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
@@ -97,6 +97,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.rule.RuleNodeStateService;
@@ -511,6 +512,10 @@ public class ActorSystemContext {
@Getter
private ResourceService resourceService;
+ @Autowired
+ @Getter
+ private TbResourceDataCache resourceDataCache;
+
@Lazy
@Autowired(required = false)
@Getter
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java
index 2959bfc8eb..c57984ef3d 100644
--- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java
@@ -51,7 +51,7 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor {
@Override
public void destroy(TbActorStopReason stopReason, Throwable cause) throws TbActorException {
log.debug("[{}] Stopping CF entity actor.", processor.tenantId);
- processor.stop();
+ processor.stop(false);
}
@Override
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
index 35539834c3..f8b61a082f 100644
--- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
@@ -48,6 +48,8 @@ import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import java.util.ArrayList;
import java.util.Collection;
@@ -91,16 +93,18 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
this.ctx = ctx;
}
- public void stop() {
- log.info("[{}][{}] Stopping entity actor.", tenantId, entityId);
+ public void stop(boolean partitionChanged) {
+ log.info(partitionChanged ?
+ "[{}][{}] Stopping entity actor due to change partition event." :
+ "[{}][{}] Stopping entity actor.",
+ tenantId, entityId);
states.clear();
ctx.stop(ctx.getSelf());
}
public void process(CalculatedFieldPartitionChangeMsg msg) {
if (!systemContext.getPartitionService().resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, entityId).isMyPartition()) {
- log.info("[{}] Stopping entity actor due to change partition event.", entityId);
- ctx.stop(ctx.getSelf());
+ stop(true);
}
}
@@ -251,6 +255,16 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (state == null) {
state = getOrInitState(ctx);
justRestored = true;
+ } else if (ctx.shouldFetchDynamicArgumentsFromDb(state)) {
+ log.debug("[{}][{}] Going to update dynamic arguments for CF.", entityId, ctx.getCfId());
+ try {
+ Map dynamicArgsFromDb = cfService.fetchDynamicArgsFromDb(ctx, entityId);
+ dynamicArgsFromDb.forEach(newArgValues::putIfAbsent);
+ var geofencingState = (GeofencingCalculatedFieldState) state;
+ geofencingState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis());
+ } catch (Exception e) {
+ throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
+ }
}
if (state.isSizeOk()) {
if (state.updateState(ctx, newArgValues) || justRestored) {
@@ -271,7 +285,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (state != null) {
return state;
} else {
- ListenableFuture stateFuture = systemContext.getCalculatedFieldProcessingService().fetchStateFromDb(ctx, entityId);
+ ListenableFuture stateFuture = cfService.fetchStateFromDb(ctx, entityId);
// Ugly but necessary. We do not expect to often fetch data from DB. Only once per pair lifetime.
// This call happens while processing the CF pack from the queue consumer. So the timeout should be relatively low.
// Alternatively, we can fetch the state outside the actor system and push separate command to create this actor,
@@ -288,7 +302,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
boolean stateSizeChecked = false;
try {
if (ctx.isInitialized() && state.isReady()) {
- CalculatedFieldResult calculationResult = state.performCalculation(ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS);
+ CalculatedFieldResult calculationResult = state.performCalculation(entityId, ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS);
state.checkStateSize(ctxId, ctx.getMaxStateSize());
stateSizeChecked = true;
if (state.isSizeOk()) {
@@ -298,7 +312,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
callback.onSuccess();
}
if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) {
- systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.getResult().toString(), null);
+ systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.toStringOrElseNull(), null);
}
}
} else {
@@ -367,7 +381,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
private Map mapToArguments(CalculatedFieldCtx ctx, AttributeScopeProto scope, List attrDataList) {
- return mapToArguments(ctx.getMainEntityArguments(), scope, attrDataList);
+ return mapToArguments(entityId, ctx.getMainEntityArguments(), ctx.getMainEntityGeofencingArgumentNames(), scope, attrDataList);
}
private Map mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List attrDataList) {
@@ -375,17 +389,23 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (argNames.isEmpty()) {
return Collections.emptyMap();
}
- return mapToArguments(argNames, scope, attrDataList);
+ List geofencingArgumentNames = ctx.getLinkedEntityGeofencingArgumentNames();
+ return mapToArguments(entityId, argNames, geofencingArgumentNames, scope, attrDataList);
}
- private Map mapToArguments(Map argNames, AttributeScopeProto scope, List attrDataList) {
+ private Map mapToArguments(EntityId entityId, Map argNames, List geofencingArgNames, AttributeScopeProto scope, List attrDataList) {
Map arguments = new HashMap<>();
for (AttributeValueProto item : attrDataList) {
ReferencedEntityKey key = new ReferencedEntityKey(item.getKey(), ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name()));
String argName = argNames.get(key);
- if (argName != null) {
- arguments.put(argName, new SingleValueArgumentEntry(item));
+ if (argName == null) {
+ continue;
+ }
+ if (geofencingArgNames.contains(argName)) {
+ arguments.put(argName, new GeofencingArgumentEntry(entityId, item));
+ continue;
}
+ arguments.put(argName, new SingleValueArgumentEntry(item));
}
return arguments;
}
@@ -395,26 +415,32 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (argNames.isEmpty()) {
return Collections.emptyMap();
}
- return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), scope, removedAttrKeys);
+ List geofencingArgumentNames = ctx.getLinkedEntityGeofencingArgumentNames();
+ return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), geofencingArgumentNames, scope, removedAttrKeys);
}
private Map mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, AttributeScopeProto scope, List removedAttrKeys) {
- return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), scope, removedAttrKeys);
+ return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), ctx.getMainEntityGeofencingArgumentNames(), scope, removedAttrKeys);
}
- private Map mapToArgumentsWithDefaultValue(Map argNames, Map configArguments, AttributeScopeProto scope, List removedAttrKeys) {
+ private Map mapToArgumentsWithDefaultValue(Map argNames, Map configArguments, List geofencingArgNames, AttributeScopeProto scope, List removedAttrKeys) {
Map arguments = new HashMap<>();
for (String removedKey : removedAttrKeys) {
ReferencedEntityKey key = new ReferencedEntityKey(removedKey, ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name()));
String argName = argNames.get(key);
- if (argName != null) {
- Argument argument = configArguments.get(argName);
- String defaultValue = (argument != null) ? argument.getDefaultValue() : null;
- arguments.put(argName, StringUtils.isNotEmpty(defaultValue)
- ? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null)
- : new SingleValueArgumentEntry());
-
+ if (argName == null) {
+ continue;
}
+ if (geofencingArgNames.contains(argName)) {
+ arguments.put(argName, new GeofencingArgumentEntry());
+ continue;
+ }
+ Argument argument = configArguments.get(argName);
+ String defaultValue = (argument != null) ? argument.getDefaultValue() : null;
+ arguments.put(argName, StringUtils.isNotEmpty(defaultValue)
+ ? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null)
+ : new SingleValueArgumentEntry());
+
}
return arguments;
}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
index c00995d3d4..9b538a4268 100644
--- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
@@ -57,6 +57,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.BiConsumer;
import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto;
@@ -255,7 +256,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId);
callback.onSuccess();
} else {
- var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService());
+ var cfCtx = getCfCtx(cf);
try {
cfCtx.init();
} catch (Exception e) {
@@ -266,11 +267,15 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
// Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx);
addLinks(cf);
- initCf(cfCtx, callback, false);
+ applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> initCfForEntity(id, cfCtx, false, cb));
}
}
}
+ private CalculatedFieldCtx getCfCtx(CalculatedField cf) {
+ return new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService(), systemContext.getRelationService());
+ }
+
private void onCfUpdated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException {
var cfId = new CalculatedFieldId(msg.getEntityId().getId());
var oldCfCtx = calculatedFields.get(cfId);
@@ -282,37 +287,37 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId);
callback.onSuccess();
} else {
- var newCfCtx = new CalculatedFieldCtx(newCf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService());
+ var newCfCtx = getCfCtx(newCf);
try {
newCfCtx.init();
} catch (Exception e) {
throw CalculatedFieldException.builder().ctx(newCfCtx).eventEntity(newCfCtx.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build();
- }
- calculatedFields.put(newCf.getId(), newCfCtx);
- List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId());
- List newCfList = new CopyOnWriteArrayList<>();
- boolean found = false;
- for (CalculatedFieldCtx oldCtx : oldCfList) {
- if (oldCtx.getCfId().equals(newCf.getId())) {
+ } finally {
+ calculatedFields.put(newCf.getId(), newCfCtx);
+ List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId());
+ List newCfList = new CopyOnWriteArrayList<>();
+ boolean found = false;
+ for (CalculatedFieldCtx oldCtx : oldCfList) {
+ if (oldCtx.getCfId().equals(newCf.getId())) {
+ newCfList.add(newCfCtx);
+ found = true;
+ } else {
+ newCfList.add(oldCtx);
+ }
+ }
+ if (!found) {
newCfList.add(newCfCtx);
- found = true;
- } else {
- newCfList.add(oldCtx);
}
+ // We use copy on write lists to safely pass the reference to another actor for the iteration.
+ // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
+ entityIdCalculatedFields.put(newCf.getEntityId(), newCfList);
+ deleteLinks(oldCfCtx);
+ addLinks(newCf);
}
- if (!found) {
- newCfList.add(newCfCtx);
- }
- entityIdCalculatedFields.put(newCf.getEntityId(), newCfList);
- deleteLinks(oldCfCtx);
- addLinks(newCf);
-
- // We use copy on write lists to safely pass the reference to another actor for the iteration.
- // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
var stateChanges = newCfCtx.hasStateChanges(oldCfCtx);
if (stateChanges || newCfCtx.hasOtherSignificantChanges(oldCfCtx)) {
- initCf(newCfCtx, callback, stateChanges);
+ applyToTargetCfEntityActors(newCfCtx, callback, (id, cb) -> initCfForEntity(id, newCfCtx, stateChanges, cb));
} else {
callback.onSuccess();
}
@@ -326,31 +331,11 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
if (cfCtx == null) {
log.debug("[{}] CF was already deleted [{}]", tenantId, cfId);
callback.onSuccess();
- } else {
- entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx);
- deleteLinks(cfCtx);
-
- EntityId entityId = cfCtx.getEntityId();
- EntityType entityType = cfCtx.getEntityId().getEntityType();
- if (isProfileEntity(entityType)) {
- var entityIds = entityProfileCache.getEntityIdsByProfileId(entityId);
- if (!entityIds.isEmpty()) {
- //TODO: no need to do this if we cache all created actors and know which one belong to us;
- var multiCallback = new MultipleTbCallback(entityIds.size(), callback);
- entityIds.forEach(id -> {
- if (isMyPartition(id, multiCallback)) {
- deleteCfForEntity(id, cfId, multiCallback);
- }
- });
- } else {
- callback.onSuccess();
- }
- } else {
- if (isMyPartition(entityId, callback)) {
- deleteCfForEntity(entityId, cfId, callback);
- }
- }
+ return;
}
+ entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx);
+ deleteLinks(cfCtx);
+ applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> deleteCfForEntity(id, cfId, cb));
}
public void onTelemetryMsg(CalculatedFieldTelemetryMsg msg) {
@@ -389,31 +374,11 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
for (var linkProto : linksList) {
var link = fromProto(linkProto);
- var targetEntityId = link.entityId();
- var targetEntityType = targetEntityId.getEntityType();
var cf = calculatedFields.get(link.cfId());
- if (EntityType.DEVICE_PROFILE.equals(targetEntityType) || EntityType.ASSET_PROFILE.equals(targetEntityType)) {
- // iterate over all entities that belong to profile and push the message for corresponding CF
- var entityIds = entityProfileCache.getEntityIdsByProfileId(targetEntityId);
- if (!entityIds.isEmpty()) {
- MultipleTbCallback multipleCallback = new MultipleTbCallback(entityIds.size(), callback);
- var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, multipleCallback);
- entityIds.forEach(entityId -> {
- if (isMyPartition(entityId, multipleCallback)) {
- log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId);
- getOrCreateActor(entityId).tell(newMsg);
- }
- });
- } else {
- callback.onSuccess();
- }
- } else {
- if (isMyPartition(targetEntityId, callback)) {
- log.debug("Pushing linked telemetry msg to specific actor [{}]", targetEntityId);
- var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, callback);
- getOrCreateActor(targetEntityId).tell(newMsg);
- }
- }
+ withTargetEntities(link.entityId(), callback, (ids, cb) -> {
+ var linkedTelemetryMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, cb);
+ ids.forEach(id -> linkedTelemetryMsgForEntity(id, linkedTelemetryMsg));
+ });
}
}
@@ -452,26 +417,9 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
return result;
}
- private void initCf(CalculatedFieldCtx cfCtx, TbCallback callback, boolean forceStateReinit) {
- EntityId entityId = cfCtx.getEntityId();
- EntityType entityType = cfCtx.getEntityId().getEntityType();
- if (isProfileEntity(entityType)) {
- var entityIds = entityProfileCache.getEntityIdsByProfileId(entityId);
- if (!entityIds.isEmpty()) {
- var multiCallback = new MultipleTbCallback(entityIds.size(), callback);
- entityIds.forEach(id -> {
- if (isMyPartition(id, multiCallback)) {
- initCfForEntity(id, cfCtx, forceStateReinit, multiCallback);
- }
- });
- } else {
- callback.onSuccess();
- }
- } else {
- if (isMyPartition(entityId, callback)) {
- initCfForEntity(entityId, cfCtx, forceStateReinit, callback);
- }
- }
+ private void linkedTelemetryMsgForEntity(EntityId entityId, EntityCalculatedFieldLinkedTelemetryMsg msg) {
+ log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId);
+ getOrCreateActor(entityId).tell(msg);
}
private void deleteCfForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) {
@@ -545,16 +493,17 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
private void initCalculatedField(CalculatedField cf) throws CalculatedFieldException {
- var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService());
+ var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService(), systemContext.getRelationService());
try {
cfCtx.init();
} catch (Exception e) {
throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build();
+ } finally {
+ calculatedFields.put(cf.getId(), cfCtx);
+ // We use copy on write lists to safely pass the reference to another actor for the iteration.
+ // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
+ entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx);
}
- calculatedFields.put(cf.getId(), cfCtx);
- // We use copy on write lists to safely pass the reference to another actor for the iteration.
- // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
- entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx);
}
private void initCalculatedFieldLink(CalculatedFieldLink link) {
@@ -584,4 +533,30 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
}
+ private void applyToTargetCfEntityActors(CalculatedFieldCtx ctx,
+ TbCallback callback,
+ BiConsumer action) {
+ withTargetEntities(ctx.getEntityId(), callback, (ids, cb) -> ids.forEach(id -> action.accept(id, cb)));
+ }
+
+ private void withTargetEntities(EntityId entityId, TbCallback parentCallback, BiConsumer, TbCallback> consumer) {
+ if (isProfileEntity(entityId.getEntityType())) {
+ var ids = entityProfileCache.getEntityIdsByProfileId(entityId);
+ if (ids.isEmpty()) {
+ parentCallback.onSuccess();
+ return;
+ }
+ var multiCallback = new MultipleTbCallback(ids.size(), parentCallback);
+ var profileEntityIds = ids.stream().filter(id -> isMyPartition(id, multiCallback)).toList();
+ if (profileEntityIds.isEmpty()) {
+ return;
+ }
+ consumer.accept(profileEntityIds, multiCallback);
+ return;
+ }
+ if (isMyPartition(entityId, parentCallback)) {
+ consumer.accept(List.of(entityId), parentCallback);
+ }
+ }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java
index d1f4c9092e..493985c97a 100644
--- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java
@@ -50,7 +50,7 @@ public class MultipleTbCallback implements TbCallback {
@Override
public void onFailure(Throwable t) {
- log.warn("[{}][{}] onFailure.", id, callback.getId());
+ log.warn("[{}][{}] onFailure.", id, callback.getId(), t);
callback.onFailure(t);
}
}
diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
index 6374e4016d..03830c1c4c 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
@@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.HasRuleEngineProfile;
+import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.alarm.Alarm;
@@ -60,6 +61,7 @@ import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
@@ -110,6 +112,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.tenant.TenantService;
@@ -770,6 +773,11 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getResourceService();
}
+ @Override
+ public TbResourceDataCache getTbResourceDataCache() {
+ return mainCtx.getResourceDataCache();
+ }
+
@Override
public OtaPackageService getOtaPackageService() {
return mainCtx.getOtaPackageService();
@@ -1054,11 +1062,20 @@ public class DefaultTbContext implements TbContext {
@Override
public void checkTenantEntity(EntityId entityId) throws TbNodeException {
- if (!this.getTenantId().equals(TenantIdLoader.findTenantId(this, entityId))) {
+ TenantId actualTenantId = TenantIdLoader.findTenantId(this, entityId);
+ if (!getTenantId().equals(actualTenantId)) {
throw new TbNodeException("Entity with id: '" + entityId + "' specified in the configuration doesn't belong to the current tenant.", true);
}
}
+ @Override
+ public & HasTenantId, I extends EntityId> void checkTenantOrSystemEntity(E entity) throws TbNodeException {
+ TenantId actualTenantId = entity.getTenantId();
+ if (!getTenantId().equals(actualTenantId) && !actualTenantId.isSysTenantId()) {
+ throw new TbNodeException("Entity with id: '" + entity.getId() + "' specified in the configuration doesn't belong to the current or system tenant.", true);
+ }
+ }
+
private static String getFailureMessage(Throwable th) {
String failureMessage;
if (th != null) {
diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
index 067a1e98c6..410b05456e 100644
--- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
+++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
@@ -116,6 +116,8 @@ public class SwaggerConfiguration {
private String appVersion;
@Value("${swagger.group_name:thingsboard}")
private String groupName;
+ @Value("${swagger.doc_expansion:list}")
+ private String docExpansion;
@Bean
public OpenAPI thingsboardApi() {
@@ -172,7 +174,7 @@ public class SwaggerConfiguration {
uiProperties.setDefaultModelExpandDepth(1);
uiProperties.setDefaultModelRendering("example");
uiProperties.setDisplayRequestDuration(false);
- uiProperties.setDocExpansion("list");
+ uiProperties.setDocExpansion(docExpansion);
uiProperties.setFilter("false");
uiProperties.setMaxDisplayedTags(null);
uiProperties.setOperationsSorter("alpha");
diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
index 2fbc89a84d..ca741ea8c6 100644
--- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
+++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
@@ -31,6 +31,7 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
@@ -38,6 +39,7 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.header.writers.CrossOriginOpenerPolicyHeaderWriter.CrossOriginOpenerPolicy;
import org.springframework.security.web.header.writers.StaticHeadersWriter;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@@ -210,9 +212,8 @@ public class ThingsboardSecurityConfiguration {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
- http.headers(headers -> headers
- .cacheControl(config -> {})
- .frameOptions(config -> {}).disable())
+ http.headers(headers -> headers.defaultsDisabled()
+ .crossOriginOpenerPolicy(coop -> coop.policy(CrossOriginOpenerPolicy.SAME_ORIGIN)))
.cors(cors -> {})
.csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(config -> {})
diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java
index 2a4cce143a..d4223d30f1 100644
--- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java
@@ -34,6 +34,9 @@ import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntitySubtype;
+import org.thingsboard.server.common.data.NameConflictPolicy;
+import org.thingsboard.server.common.data.NameConflictStrategy;
+import org.thingsboard.server.common.data.UniquifyStrategy;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetInfo;
import org.thingsboard.server.common.data.asset.AssetSearchQuery;
@@ -76,6 +79,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_
import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC;
+import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
@@ -83,6 +88,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
+import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
import static org.thingsboard.server.controller.EdgeController.EDGE_ID;
@@ -137,10 +143,16 @@ public class AssetController extends BaseController {
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/asset", method = RequestMethod.POST)
@ResponseBody
- public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset) throws Exception {
+ public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset,
+ @Parameter(description = NAME_CONFLICT_POLICY_DESC)
+ @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
+ @Parameter(description = UNIQUIFY_SEPARATOR_DESC)
+ @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
+ @Parameter(description = UNIQUIFY_STRATEGY_DESC)
+ @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception {
asset.setTenantId(getTenantId());
checkEntity(asset.getId(), asset, Resource.ASSET);
- return tbAssetService.save(asset, getCurrentUser());
+ return tbAssetService.save(asset, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Delete asset (deleteAsset)",
diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
index 5945355ef8..c5b077c128 100644
--- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
@@ -289,7 +289,6 @@ public class CalculatedFieldController extends BaseController {
default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities.");
}
}
-
}
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
index a87864726b..276d2f615c 100644
--- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
+++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
@@ -1744,4 +1744,18 @@ public class ControllerConstants {
MARKDOWN_CODE_BLOCK_END ;
protected static final String SECURITY_WRITE_CHECK = " Security check is performed to verify that the user has 'WRITE' permission for the entity (entities).";
+
+ public static final String NAME_CONFLICT_POLICY_DESC = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " +
+ " If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " +
+ " UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.";
+
+ public static final String UNIQUIFY_SEPARATOR_DESC = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " +
+ "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity name 'test-name', " +
+ "created entity will have name like 'test-name-7fsh4f'.";
+
+ public static final String UNIQUIFY_STRATEGY_DESC = "Optional value of uniquify strategy used by UNIQUIFY policy. Possible values: RANDOM or INCREMENTAL. " +
+ "By default, RANDOM strategy is used, which means random alphanumeric string will be added as a suffix to entity name. " +
+ "INCREMENTAL implies the first possible number starting from 1 will be added as a name suffix. " +
+ "For example, strategy is UNIQUIFY, uniquify strategy is INCREMENTAL; if a name conflict occurs for entity name 'test-name', " +
+ "created entity will have name like 'test-name-1.";
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java
index 03ac9c60fa..fca7ce3e86 100644
--- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java
@@ -32,6 +32,9 @@ import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Customer;
+import org.thingsboard.server.common.data.NameConflictStrategy;
+import org.thingsboard.server.common.data.NameConflictPolicy;
+import org.thingsboard.server.common.data.UniquifyStrategy;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
@@ -47,6 +50,8 @@ import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID;
import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD;
+import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC;
+import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
@@ -54,6 +59,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
+import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
@RestController
@@ -128,10 +134,16 @@ public class CustomerController extends BaseController {
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@RequestMapping(value = "/customer", method = RequestMethod.POST)
@ResponseBody
- public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer) throws Exception {
+ public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer,
+ @Parameter(description = NAME_CONFLICT_POLICY_DESC)
+ @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
+ @Parameter(description = UNIQUIFY_SEPARATOR_DESC)
+ @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
+ @Parameter(description = UNIQUIFY_STRATEGY_DESC)
+ @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception {
customer.setTenantId(getTenantId());
checkEntity(customer.getId(), customer, Resource.CUSTOMER);
- return tbCustomerService.save(customer, getCurrentUser());
+ return tbCustomerService.save(customer, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Delete Customer (deleteCustomer)",
diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java
index 61864dbf63..8bbf4ae2f6 100644
--- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java
@@ -46,8 +46,11 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceInfo;
import org.thingsboard.server.common.data.DeviceInfoFilter;
import org.thingsboard.server.common.data.EntitySubtype;
+import org.thingsboard.server.common.data.NameConflictPolicy;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest;
import org.thingsboard.server.common.data.Tenant;
+import org.thingsboard.server.common.data.UniquifyStrategy;
import org.thingsboard.server.common.data.device.DeviceSearchQuery;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
@@ -108,6 +111,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_
import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC;
+import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
@@ -117,6 +122,7 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHO
import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
+import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
import static org.thingsboard.server.controller.EdgeController.EDGE_ID;
@@ -177,14 +183,21 @@ public class DeviceController extends BaseController {
@ResponseBody
public Device saveDevice(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the device.") @RequestBody Device device,
@Parameter(description = "Optional value of the device credentials to be used during device creation. " +
- "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken) throws Exception {
+ "If omitted, access token will be auto-generated.")
+ @RequestParam(name = "accessToken", required = false) String accessToken,
+ @Parameter(description = NAME_CONFLICT_POLICY_DESC)
+ @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
+ @Parameter(description = UNIQUIFY_SEPARATOR_DESC)
+ @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
+ @Parameter(description = UNIQUIFY_STRATEGY_DESC)
+ @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception {
device.setTenantId(getCurrentUser().getTenantId());
if (device.getId() != null) {
checkDeviceId(device.getId(), Operation.WRITE);
} else {
checkEntity(null, device, Resource.DEVICE);
}
- return tbDeviceService.save(device, accessToken, getCurrentUser());
+ return tbDeviceService.save(device, accessToken, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Create Device (saveDevice) with credentials ",
@@ -209,12 +222,18 @@ public class DeviceController extends BaseController {
@RequestMapping(value = "/device-with-credentials", method = RequestMethod.POST)
@ResponseBody
public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.")
- @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials) throws ThingsboardException {
+ @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials,
+ @Parameter(description = NAME_CONFLICT_POLICY_DESC)
+ @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
+ @Parameter(description = UNIQUIFY_SEPARATOR_DESC)
+ @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
+ @Parameter(description = UNIQUIFY_STRATEGY_DESC)
+ @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws ThingsboardException {
Device device = deviceAndCredentials.getDevice();
DeviceCredentials credentials = deviceAndCredentials.getCredentials();
device.setTenantId(getCurrentUser().getTenantId());
checkEntity(device.getId(), device, Resource.DEVICE);
- return tbDeviceService.saveDeviceWithCredentials(device, credentials, getCurrentUser());
+ return tbDeviceService.saveDeviceWithCredentials(device, credentials, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Delete device (deleteDevice)",
diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java
index b1b6b6b1e3..67fc02ab29 100644
--- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java
@@ -34,6 +34,9 @@ import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.EntityViewInfo;
+import org.thingsboard.server.common.data.NameConflictPolicy;
+import org.thingsboard.server.common.data.NameConflictStrategy;
+import org.thingsboard.server.common.data.UniquifyStrategy;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@@ -69,6 +72,8 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE;
import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION;
+import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC;
+import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
@@ -76,6 +81,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
+import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC;
import static org.thingsboard.server.controller.EdgeController.EDGE_ID;
/**
@@ -128,7 +134,13 @@ public class EntityViewController extends BaseController {
@ResponseBody
public EntityView saveEntityView(
@Parameter(description = "A JSON object representing the entity view.")
- @RequestBody EntityView entityView) throws Exception {
+ @RequestBody EntityView entityView,
+ @Parameter(description = NAME_CONFLICT_POLICY_DESC)
+ @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
+ @Parameter(description = UNIQUIFY_SEPARATOR_DESC)
+ @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
+ @Parameter(description = UNIQUIFY_STRATEGY_DESC)
+ @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception {
entityView.setTenantId(getCurrentUser().getTenantId());
EntityView existingEntityView = null;
if (entityView.getId() == null) {
@@ -137,7 +149,7 @@ public class EntityViewController extends BaseController {
} else {
existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE);
}
- return tbEntityViewService.save(entityView, existingEntityView, getCurrentUser());
+ return tbEntityViewService.save(entityView, existingEntityView, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Delete entity view (deleteEntityView)",
diff --git a/application/src/main/java/org/thingsboard/server/controller/ImageController.java b/application/src/main/java/org/thingsboard/server/controller/ImageController.java
index f9ec7fd844..9288484f86 100644
--- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java
@@ -300,6 +300,7 @@ public class ImageController extends BaseController {
tbImageService.putETag(cacheKey, descriptor.getEtag());
var result = ResponseEntity.ok()
.header("Content-Type", descriptor.getMediaType())
+ .header("Content-Security-Policy", "default-src 'none'")
.eTag(descriptor.getEtag());
if (!cacheKey.isPublic()) {
result
diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java
index f0ec727896..7e7cd84a12 100644
--- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java
@@ -27,6 +27,8 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.NameConflictPolicy;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest;
import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@@ -37,6 +39,7 @@ import org.thingsboard.server.service.lwm2m.LwM2MService;
import java.util.Map;
+import static org.thingsboard.server.common.data.NameConflictStrategy.DEFAULT;
import static org.thingsboard.server.controller.ControllerConstants.IS_BOOTSTRAP_SERVER_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
@@ -73,6 +76,6 @@ public class Lwm2mController extends BaseController {
public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException {
Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class));
DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class));
- return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials));
+ return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator(), DEFAULT.uniquifyStrategy());
}
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java
index befd0baec5..6a66887989 100644
--- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java
@@ -20,6 +20,7 @@ import io.swagger.v3.oas.annotations.Parameter;
import jakarta.annotation.Nullable;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -60,7 +61,10 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_
@RequestMapping(TbUrlConstants.RULE_ENGINE_URL_PREFIX)
@Slf4j
public class RuleEngineController extends BaseController {
- public static final int DEFAULT_TIMEOUT = 10000;
+
+ @Value("${server.rest.rule_engine.response_timeout:10000}")
+ public int defaultResponseTimeout;
+
private static final String MSG_DESCRIPTION_PREFIX = "Creates the Message with type 'REST_API_REQUEST' and payload taken from the request body. ";
private static final String MSG_DESCRIPTION = "This method allows you to extend the regular platform API with the power of Rule Engine. You may use default and custom rule nodes to handle the message. " +
"The generated message contains two important metadata fields:\n\n" +
@@ -85,7 +89,7 @@ public class RuleEngineController extends BaseController {
public DeferredResult handleRuleEngineRequest(
@Parameter(description = "A JSON value representing the message.", required = true)
@RequestBody String requestBody) throws ThingsboardException {
- return handleRuleEngineRequest(null, null, null, DEFAULT_TIMEOUT, requestBody);
+ return handleRuleEngineRequest(null, null, null, defaultResponseTimeout, requestBody);
}
@ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequest)",
@@ -104,7 +108,7 @@ public class RuleEngineController extends BaseController {
@PathVariable("entityId") String entityIdStr,
@Parameter(description = "A JSON value representing the message.", required = true)
@RequestBody String requestBody) throws ThingsboardException {
- return handleRuleEngineRequest(entityType, entityIdStr, null, DEFAULT_TIMEOUT, requestBody);
+ return handleRuleEngineRequest(entityType, entityIdStr, null, defaultResponseTimeout, requestBody);
}
@ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequest)",
diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
index 29f4daa783..b9968aefa9 100644
--- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
@@ -162,6 +162,8 @@ public class SystemInfoController extends BaseController {
}
systemParams.setMaxArgumentsPerCF(tenantProfileConfiguration.getMaxArgumentsPerCF());
systemParams.setMaxDataPointsPerRollingArg(tenantProfileConfiguration.getMaxDataPointsPerRollingArg());
+ systemParams.setMinAllowedScheduledUpdateIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedScheduledUpdateIntervalInSecForCF());
+ systemParams.setMaxRelationLevelPerCfArgument(tenantProfileConfiguration.getMaxRelationLevelPerCfArgument());
systemParams.setTrendzSettings(trendzSettingsService.findTrendzSettings(currentUser.getTenantId()));
}
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))
diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
index b23603f6a2..54d2494679 100644
--- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
@@ -55,14 +55,17 @@ import org.thingsboard.server.common.data.util.ThrowingSupplier;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.resource.TbResourceService;
+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;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
+import java.util.UUID;
import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER;
import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION;
@@ -263,6 +266,20 @@ public class TbResourceController extends BaseController {
}
}
+ @ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)")
+ @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
+ @GetMapping(value = "/resource", params = {"resourceIds"})
+ public List getSystemOrTenantResourcesByIds(
+ @Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")))
+ @RequestParam("resourceIds") Set resourceUuids) throws ThingsboardException {
+ SecurityUser user = getCurrentUser();
+ List resourceIds = new ArrayList<>();
+ for (UUID resourceId : resourceUuids) {
+ resourceIds.add(new TbResourceId(resourceId));
+ }
+ return resourceService.findSystemOrTenantResourcesByIds(user.getTenantId(), resourceIds);
+ }
+
@ApiOperation(value = "Get All Resource Infos (getAllResources)",
notes = "Returns a page of Resource Info objects owned by tenant. " +
PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH)
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
index 69dd98f47f..c631f1a3d0 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
@@ -32,8 +32,10 @@ import dev.langchain4j.model.chat.request.ChatRequestParameters;
import dev.langchain4j.model.github.GitHubModelsChatModel;
import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
import dev.langchain4j.model.mistralai.MistralAiChatModel;
+import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
+import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
@@ -43,10 +45,12 @@ import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelC
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer;
import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
@@ -54,7 +58,11 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.util.Base64;
+
+import static java.util.Collections.singletonMap;
@Component
class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer {
@@ -62,6 +70,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) {
return OpenAiChatModel.builder()
+ .baseUrl(chatModelConfig.providerConfig().baseUrl())
.apiKey(chatModelConfig.providerConfig().apiKey())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
@@ -134,7 +143,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
// set request timeout from model config
if (chatModelConfig.timeoutSeconds() != null) {
- retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(chatModelConfig.timeoutSeconds()));
+ retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds()));
}
// set updated retry settings
@@ -262,6 +271,35 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
.build();
}
+ @Override
+ public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) {
+ var builder = OllamaChatModel.builder()
+ .baseUrl(chatModelConfig.providerConfig().baseUrl())
+ .modelName(chatModelConfig.modelId())
+ .temperature(chatModelConfig.temperature())
+ .topP(chatModelConfig.topP())
+ .topK(chatModelConfig.topK())
+ .numCtx(chatModelConfig.contextLength())
+ .numPredict(chatModelConfig.maxOutputTokens())
+ .timeout(toDuration(chatModelConfig.timeoutSeconds()))
+ .maxRetries(chatModelConfig.maxRetries());
+
+ var auth = chatModelConfig.providerConfig().auth();
+ if (auth instanceof OllamaProviderConfig.OllamaAuth.Basic basicAuth) {
+ String credentials = basicAuth.username() + ":" + basicAuth.password();
+ String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+ builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials));
+ } else if (auth instanceof OllamaProviderConfig.OllamaAuth.Token tokenAuth) {
+ builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Bearer " + tokenAuth.token()));
+ } else if (auth instanceof OllamaProviderConfig.OllamaAuth.None) {
+ // do nothing
+ } else {
+ throw new UnsupportedOperationException("Unknown authentication type: " + auth.getClass().getSimpleName());
+ }
+
+ return builder.build();
+ }
+
private static Duration toDuration(Integer timeoutSeconds) {
return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null;
}
diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java
index 3dedea999c..84025b3cee 100644
--- a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java
+++ b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java
@@ -442,13 +442,13 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService
boolean check(long threshold, long warnThreshold, long value);
}
- private void checkStartOfNextCycle() {
+ public void checkStartOfNextCycle() {
updateLock.lock();
try {
long now = System.currentTimeMillis();
myUsageStates.values().forEach(state -> {
if ((state.getNextCycleTs() < now) && (now - state.getNextCycleTs() < TimeUnit.HOURS.toMillis(1))) {
- state.setCycles(state.getNextCycleTs(), SchedulerUtils.getStartOfNextNextMonth());
+ state.setCycles(state.getNextCycleTs(), SchedulerUtils.getStartOfNextMonth());
if (log.isTraceEnabled()) {
log.trace("[{}][{}] Updating state cycles (currentCycleTs={},nextCycleTs={})", state.getTenantId(), state.getEntityId(), state.getCurrentCycleTs(), state.getNextCycleTs());
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
new file mode 100644
index 0000000000..fa10a49503
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
@@ -0,0 +1,252 @@
+/**
+ * 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.cf;
+
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.common.util.ThingsBoardExecutors;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.Aggregation;
+import org.thingsboard.server.common.data.kv.AttributeKvEntry;
+import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
+import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
+import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
+import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
+import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.common.data.relation.RelationTypeGroup;
+import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
+import org.thingsboard.server.dao.attributes.AttributesService;
+import org.thingsboard.server.dao.relation.RelationService;
+import org.thingsboard.server.dao.timeseries.TimeseriesService;
+import org.thingsboard.server.dao.usagerecord.ApiLimitService;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultKvEntry;
+import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType;
+import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument;
+
+@Data
+@Slf4j
+public abstract class AbstractCalculatedFieldProcessingService {
+
+ protected final AttributesService attributesService;
+ protected final TimeseriesService timeseriesService;
+ protected final ApiLimitService apiLimitService;
+ protected final RelationService relationService;
+
+ protected ListeningExecutorService calculatedFieldCallbackExecutor;
+
+ @PostConstruct
+ public void init() {
+ calculatedFieldCallbackExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(
+ Math.max(4, Runtime.getRuntime().availableProcessors()), getExecutorNamePrefix()));
+ }
+
+ @PreDestroy
+ public void stop() {
+ if (calculatedFieldCallbackExecutor != null) {
+ calculatedFieldCallbackExecutor.shutdownNow();
+ }
+ }
+
+ protected abstract String getExecutorNamePrefix();
+
+ public ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
+ Map> argFutures = switch (ctx.getCalculatedField().getType()) {
+ case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false);
+ case SIMPLE, SCRIPT -> {
+ Map> futures = new HashMap<>();
+ for (var entry : ctx.getArguments().entrySet()) {
+ var argEntityId = resolveEntityId(entityId, entry.getValue());
+ var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), System.currentTimeMillis());
+ futures.put(entry.getKey(), argValueFuture);
+ }
+ yield futures;
+ }
+ };
+ return Futures.whenAllComplete(argFutures.values()).call(() -> {
+ var result = createStateByType(ctx);
+ result.updateState(ctx, resolveArgumentFutures(argFutures));
+ // TODO: move to state.init() method after merge with alarm rules 2.0
+ if (ctx.hasRelationQueryDynamicArguments() && result instanceof GeofencingCalculatedFieldState geofencingCalculatedFieldState) {
+ geofencingCalculatedFieldState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis());
+ }
+ return result;
+ }, MoreExecutors.directExecutor());
+ }
+
+ protected EntityId resolveEntityId(EntityId entityId, Argument argument) {
+ return argument.getRefEntityId() != null ? argument.getRefEntityId() : entityId;
+ }
+
+ protected Map resolveArgumentFutures(Map> argFutures) {
+ return argFutures.entrySet().stream()
+ .collect(Collectors.toMap(
+ Map.Entry::getKey, // Keep the key as is
+ entry -> {
+ try {
+ return entry.getValue().get();
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ throw new RuntimeException("Failed to fetch " + entry.getKey() + ": " + cause.getMessage(), cause);
+ } catch (InterruptedException e) {
+ throw new RuntimeException("Failed to fetch" + entry.getKey(), e);
+ }
+ }
+ ));
+ }
+
+ protected Map> fetchGeofencingCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, boolean dynamicArgumentsOnly) {
+ Map> argFutures = new HashMap<>();
+ Set> entries = ctx.getArguments().entrySet();
+ if (dynamicArgumentsOnly) {
+ entries = entries.stream()
+ .filter(entry -> entry.getValue().hasDynamicSource())
+ .collect(Collectors.toSet());
+ }
+ for (var entry : entries) {
+ switch (entry.getKey()) {
+ case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY ->
+ argFutures.put(entry.getKey(), fetchArgumentValue(ctx.getTenantId(), entityId, entry.getValue(), System.currentTimeMillis()));
+ default -> {
+ var resolvedEntityIdsFuture = resolveGeofencingEntityIds(ctx.getTenantId(), entityId, entry);
+ argFutures.put(entry.getKey(), Futures.transformAsync(resolvedEntityIdsFuture, resolvedEntityIds ->
+ fetchGeofencingKvEntry(ctx.getTenantId(), resolvedEntityIds, entry.getValue()), MoreExecutors.directExecutor()));
+ }
+ }
+ }
+ return argFutures;
+ }
+
+ private ListenableFuture> resolveGeofencingEntityIds(TenantId tenantId, EntityId entityId, Map.Entry entry) {
+ Argument value = entry.getValue();
+ if (value.getRefEntityId() != null) {
+ return Futures.immediateFuture(List.of(value.getRefEntityId()));
+ }
+ if (!value.hasDynamicSource()) {
+ return Futures.immediateFuture(List.of(entityId));
+ }
+ var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration();
+ return switch (refDynamicSourceConfiguration.getType()) {
+ case RELATION_PATH_QUERY -> {
+ var configuration = (RelationPathQueryDynamicSourceConfiguration) refDynamicSourceConfiguration;
+ yield Futures.transform(relationService.findByRelationPathQueryAsync(tenantId, configuration.toRelationPathQuery(entityId)),
+ configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
+ }
+ };
+ }
+
+ private ListenableFuture fetchGeofencingKvEntry(TenantId tenantId, List geofencingEntities, Argument argument) {
+ if (argument.getRefEntityKey().getType() != ArgumentType.ATTRIBUTE) {
+ throw new IllegalStateException("Unsupported argument key type: " + argument.getRefEntityKey().getType());
+ }
+ List>> kvFutures = geofencingEntities.stream()
+ .map(entityId -> {
+ var attributesFuture = attributesService.find(
+ tenantId,
+ entityId,
+ argument.getRefEntityKey().getScope(),
+ argument.getRefEntityKey().getKey()
+ );
+ return Futures.transform(attributesFuture, resultOpt ->
+ Map.entry(entityId, resultOpt.orElseGet(() ->
+ new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))),
+ calculatedFieldCallbackExecutor
+ );
+ }).collect(Collectors.toList());
+
+ ListenableFuture>> allFutures = Futures.allAsList(kvFutures);
+
+ return Futures.transform(allFutures, entries -> ArgumentEntry.createGeofencingValueArgument(entries.stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))), MoreExecutors.directExecutor());
+ }
+
+ protected ListenableFuture fetchArgumentValue(TenantId tenantId, EntityId entityId, Argument argument, long startTs) {
+ return switch (argument.getRefEntityKey().getType()) {
+ case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument, startTs);
+ case ATTRIBUTE -> fetchAttribute(tenantId, entityId, argument, startTs);
+ case TS_LATEST -> fetchTsLatest(tenantId, entityId, argument, startTs);
+ };
+ }
+
+ private ListenableFuture fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument, long queryEndTs) {
+ long argTimeWindow = argument.getTimeWindow() == 0 ? queryEndTs : argument.getTimeWindow();
+ long startInterval = queryEndTs - argTimeWindow;
+ ReadTsKvQuery query = buildTsRollingQuery(tenantId, argument, startInterval, queryEndTs);
+
+ log.trace("[{}][{}] Fetching timeseries for query {}", tenantId, entityId, query);
+ ListenableFuture> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query));
+ return Futures.transform(tsRollingFuture, tsRolling -> {
+ log.debug("[{}][{}] Fetched {} timeseries for query {}", tenantId, entityId, tsRolling == null ? 0 : tsRolling.size(), query);
+ return ArgumentEntry.createTsRollingArgument(tsRolling, query.getLimit(), argTimeWindow);
+ }, calculatedFieldCallbackExecutor);
+ }
+
+ private ListenableFuture fetchAttribute(TenantId tenantId, EntityId entityId, Argument argument, long defaultLastUpdateTs) {
+ log.trace("[{}][{}] Fetching attribute for key {}", tenantId, entityId, argument.getRefEntityKey());
+ var attributeOptFuture = attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey());
+
+ return Futures.transform(attributeOptFuture, attrOpt -> {
+ log.debug("[{}][{}] Fetched attribute for key {}: {}", tenantId, entityId, argument.getRefEntityKey(), attrOpt);
+ AttributeKvEntry attributeKvEntry = attrOpt.orElseGet(() -> new BaseAttributeKvEntry(createDefaultKvEntry(argument), defaultLastUpdateTs, 0L));
+ return transformSingleValueArgument(Optional.of(attributeKvEntry));
+ }, calculatedFieldCallbackExecutor);
+ }
+
+ protected ListenableFuture fetchTsLatest(TenantId tenantId, EntityId entityId, Argument argument, long startTs) {
+ String timeseriesKey = argument.getRefEntityKey().getKey();
+ log.trace("[{}][{}] Fetching latest timeseries {}", tenantId, entityId, timeseriesKey);
+ return transformSingleValueArgument(
+ Futures.transform(
+ timeseriesService.findLatest(tenantId, entityId, timeseriesKey),
+ result -> {
+ log.debug("[{}][{}] Fetched latest timeseries {}: {}", tenantId, entityId, timeseriesKey, result);
+ return result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L)));
+ }, calculatedFieldCallbackExecutor));
+ }
+
+ private ReadTsKvQuery buildTsRollingQuery(TenantId tenantId, Argument argument, long startTs, long endTs) {
+ long maxDataPoints = apiLimitService.getLimit(
+ tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);
+ int argumentLimit = argument.getLimit();
+ int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argumentLimit;
+ return new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, endTs, 0, limit, Aggregation.NONE);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java
index 847caccaff..86ed174485 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java
@@ -34,6 +34,8 @@ public interface CalculatedFieldProcessingService {
ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId);
+ Map fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId);
+
Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments);
void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult calculationResult, List cfIds, TbCallback callback);
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
index 49acf6917c..c779c27419 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
@@ -34,4 +34,8 @@ public final class CalculatedFieldResult {
(result.isTextual() && result.asText().isEmpty());
}
+ public String toStringOrElseNull() {
+ return result == null ? null : result.toString();
+ }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
index f510857d68..dfe30a0e55 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
@@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
+import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
@@ -52,6 +53,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
private final CalculatedFieldService calculatedFieldService;
private final TbelInvokeService tbelInvokeService;
private final ApiLimitService apiLimitService;
+ private final RelationService relationService;
private final ConcurrentMap calculatedFields = new ConcurrentHashMap<>();
private final ConcurrentMap> entityIdCalculatedFields = new ConcurrentHashMap<>();
@@ -111,7 +113,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
if (ctx == null) {
CalculatedField calculatedField = getCalculatedField(calculatedFieldId);
if (calculatedField != null) {
- ctx = new CalculatedFieldCtx(calculatedField, tbelInvokeService, apiLimitService);
+ ctx = new CalculatedFieldCtx(calculatedField, tbelInvokeService, apiLimitService, relationService);
calculatedFieldsCtx.put(calculatedFieldId, ctx);
log.debug("[{}] Put calculated field ctx into cache: {}", calculatedFieldId, ctx);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
index f2a6916751..17dca5dd64 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
@@ -15,46 +15,28 @@
*/
package org.thingsboard.server.service.cf;
-import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import jakarta.annotation.PostConstruct;
-import jakarta.annotation.PreDestroy;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.stereotype.Service;
-import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg;
import org.thingsboard.server.actors.calculatedField.MultipleTbCallback;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityType;
-import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
-import org.thingsboard.server.common.data.kv.Aggregation;
-import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
-import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
-import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
-import org.thingsboard.server.common.data.kv.BooleanDataEntry;
-import org.thingsboard.server.common.data.kv.DoubleDataEntry;
-import org.thingsboard.server.common.data.kv.KvEntry;
-import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
-import org.thingsboard.server.common.data.kv.StringDataEntry;
-import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
-import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.attributes.AttributesService;
+import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto;
@@ -70,20 +52,12 @@ import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
-import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
-import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState;
-import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
-import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Optional;
import java.util.UUID;
-import java.util.concurrent.ExecutionException;
-import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.DataConstants.SCOPE;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@@ -91,76 +65,53 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@TbRuleEngineComponent
@Service
@Slf4j
-@RequiredArgsConstructor
-public class DefaultCalculatedFieldProcessingService implements CalculatedFieldProcessingService {
+public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedFieldProcessingService implements CalculatedFieldProcessingService {
- private final AttributesService attributesService;
- private final TimeseriesService timeseriesService;
private final TbClusterService clusterService;
- private final ApiLimitService apiLimitService;
private final PartitionService partitionService;
- private ListeningExecutorService calculatedFieldCallbackExecutor;
-
- @PostConstruct
- public void init() {
- calculatedFieldCallbackExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(
- Math.max(4, Runtime.getRuntime().availableProcessors()), "calculated-field-callback"));
+ public DefaultCalculatedFieldProcessingService(AttributesService attributesService,
+ TimeseriesService timeseriesService,
+ ApiLimitService apiLimitService,
+ RelationService relationService,
+ TbClusterService clusterService,
+ PartitionService partitionService) {
+ super(attributesService, timeseriesService, apiLimitService, relationService);
+ this.clusterService = clusterService;
+ this.partitionService = partitionService;
}
- @PreDestroy
- public void stop() {
- if (calculatedFieldCallbackExecutor != null) {
- calculatedFieldCallbackExecutor.shutdownNow();
- }
+ @Override
+ protected String getExecutorNamePrefix() {
+ return "calculated-field-callback";
}
@Override
public ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
- Map> argFutures = new HashMap<>();
- for (var entry : ctx.getArguments().entrySet()) {
- var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId;
- var argValueFuture = fetchKvEntry(ctx.getTenantId(), argEntityId, entry.getValue());
- argFutures.put(entry.getKey(), argValueFuture);
+ return super.fetchStateFromDb(ctx, entityId);
+ }
+
+ @Override
+ public Map fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
+ // only geofencing calculated fields supports dynamic arguments scheduled updates
+ if (!ctx.getCalculatedField().getType().equals(CalculatedFieldType.GEOFENCING)) {
+ return Map.of();
}
- return Futures.whenAllComplete(argFutures.values()).call(() -> {
- var result = createStateByType(ctx);
- result.updateState(ctx, argFutures.entrySet().stream()
- .collect(Collectors.toMap(
- Entry::getKey, // Keep the key as is
- entry -> {
- try {
- // Resolve the future to get the value
- return entry.getValue().get();
- } catch (ExecutionException | InterruptedException e) {
- throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e);
- }
- }
- )));
- return result;
- }, calculatedFieldCallbackExecutor);
+ return resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true));
}
@Override
public Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments) {
Map> argFutures = new HashMap<>();
for (var entry : arguments.entrySet()) {
- var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId;
- var argValueFuture = fetchKvEntry(tenantId, argEntityId, entry.getValue());
+ if (entry.getValue().hasDynamicSource()) {
+ continue;
+ }
+ var argEntityId = resolveEntityId(entityId, entry.getValue());
+ var argValueFuture = fetchArgumentValue(tenantId, argEntityId, entry.getValue(), System.currentTimeMillis());
argFutures.put(entry.getKey(), argValueFuture);
}
- return argFutures.entrySet().stream()
- .collect(Collectors.toMap(
- Entry::getKey, // Keep the key as is
- entry -> {
- try {
- // Resolve the future to get the value
- return entry.getValue().get();
- } catch (ExecutionException | InterruptedException e) {
- throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e);
- }
- }
- ));
+ return resolveArgumentFutures(argFutures);
}
@Override
@@ -169,7 +120,7 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
OutputType type = calculatedFieldResult.getType();
TbMsgType msgType = OutputType.ATTRIBUTES.equals(type) ? TbMsgType.POST_ATTRIBUTES_REQUEST : TbMsgType.POST_TELEMETRY_REQUEST;
TbMsgMetaData md = OutputType.ATTRIBUTES.equals(type) ? new TbMsgMetaData(Map.of(SCOPE, calculatedFieldResult.getScope().name())) : TbMsgMetaData.EMPTY;
- TbMsg msg = TbMsg.newMsg().type(msgType).originator(entityId).previousCalculatedFieldIds(cfIds).metaData(md).data(calculatedFieldResult.getResult().toString()).build();
+ TbMsg msg = TbMsg.newMsg().type(msgType).originator(entityId).previousCalculatedFieldIds(cfIds).metaData(md).data(calculatedFieldResult.toStringOrElseNull()).build();
clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() {
@Override
public void onSuccess(TbQueueMsgMetadata metadata) {
@@ -241,69 +192,6 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
return builder.build();
}
- private ListenableFuture fetchKvEntry(TenantId tenantId, EntityId entityId, Argument argument) {
- return switch (argument.getRefEntityKey().getType()) {
- case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument);
- case ATTRIBUTE -> transformSingleValueArgument(
- Futures.transform(
- attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()),
- result -> result.or(() -> Optional.of(new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))),
- calculatedFieldCallbackExecutor)
- );
- case TS_LATEST -> transformSingleValueArgument(
- Futures.transform(
- timeseriesService.findLatest(tenantId, entityId, argument.getRefEntityKey().getKey()),
- result -> result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L))),
- calculatedFieldCallbackExecutor));
- };
- }
-
- private ListenableFuture transformSingleValueArgument(ListenableFuture> kvEntryFuture) {
- return Futures.transform(kvEntryFuture, kvEntry -> {
- if (kvEntry.isPresent() && kvEntry.get().getValue() != null) {
- return ArgumentEntry.createSingleValueArgument(kvEntry.get());
- } else {
- return new SingleValueArgumentEntry();
- }
- }, calculatedFieldCallbackExecutor);
- }
-
- private ListenableFuture fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument) {
- long currentTime = System.currentTimeMillis();
- long timeWindow = argument.getTimeWindow() == 0 ? System.currentTimeMillis() : argument.getTimeWindow();
- long startTs = currentTime - timeWindow;
- long maxDataPoints = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);
- int argumentLimit = argument.getLimit();
- int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argument.getLimit();
-
- ReadTsKvQuery query = new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, currentTime, 0, limit, Aggregation.NONE);
- ListenableFuture> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query));
-
- return Futures.transform(tsRollingFuture, tsRolling -> tsRolling == null ? new TsRollingArgumentEntry(limit, timeWindow) : ArgumentEntry.createTsRollingArgument(tsRolling, limit, timeWindow), calculatedFieldCallbackExecutor);
- }
-
- private KvEntry createDefaultKvEntry(Argument argument) {
- String key = argument.getRefEntityKey().getKey();
- String defaultValue = argument.getDefaultValue();
- if (StringUtils.isBlank(defaultValue)) {
- return new StringDataEntry(key, null);
- }
- if (NumberUtils.isParsable(defaultValue)) {
- return new DoubleDataEntry(key, Double.parseDouble(defaultValue));
- }
- if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) {
- return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue));
- }
- return new StringDataEntry(key, defaultValue);
- }
-
- private CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) {
- return switch (ctx.getCfType()) {
- case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames());
- case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames());
- };
- }
-
private static class TbCallbackWrapper implements TbQueueCallback {
private final TbCallback callback;
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
index 83e10b8194..2d43883131 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
@@ -19,10 +19,13 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.script.api.tbel.TbelCfArg;
+import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import java.util.List;
+import java.util.Map;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
@@ -31,7 +34,8 @@ import java.util.List;
)
@JsonSubTypes({
@JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"),
- @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING")
+ @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"),
+ @JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING")
})
public interface ArgumentEntry {
@@ -58,4 +62,8 @@ public interface ArgumentEntry {
return new TsRollingArgumentEntry(kvEntries, limit, timeWindow);
}
+ static ArgumentEntry createGeofencingValueArgument(Map entityIdkvEntryMap) {
+ return new GeofencingArgumentEntry(entityIdkvEntryMap);
+ }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
index 68f973c7c1..876bfa2a3f 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
@@ -16,5 +16,5 @@
package org.thingsboard.server.service.cf.ctx.state;
public enum ArgumentEntryType {
- SINGLE_VALUE, TS_ROLLING
+ SINGLE_VALUE, TS_ROLLING, GEOFENCING
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
index e21d56b6d2..6d877331bd 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
@@ -25,8 +25,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto;
-
@Data
@AllArgsConstructor
public abstract class BaseCalculatedFieldState implements CalculatedFieldState {
@@ -64,7 +62,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState {
boolean entryUpdated;
if (existingEntry == null || newEntry.isForceResetPrevious()) {
- validateNewEntry(newEntry);
+ validateNewEntry(key, newEntry);
arguments.put(key, newEntry);
entryUpdated = true;
} else {
@@ -95,19 +93,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState {
}
}
- @Override
- public void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) {
- if (entry instanceof TsRollingArgumentEntry) {
- return;
- }
- if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
- if (ctx.getMaxSingleValueArgumentSize() > 0 && toSingleValueArgumentProto(name, singleValueArgumentEntry).getSerializedSize() > ctx.getMaxSingleValueArgumentSize()) {
- throw new IllegalArgumentException("Single value size exceeds the maximum allowed limit. The argument will not be used for calculation.");
- }
- }
- }
-
- protected abstract void validateNewEntry(ArgumentEntry newEntry);
+ protected void validateNewEntry(String key, ArgumentEntry newEntry) {}
private void updateLastUpdateTimestamp(ArgumentEntry entry) {
long newTs = this.latestTimestamp;
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
index 2e3321eece..650aaaa169 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
@@ -25,10 +25,13 @@ import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
-import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.ExpressionBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
@@ -36,14 +39,17 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.util.ProtoUtils;
+import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.TimeUnit;
import static org.thingsboard.common.util.ExpressionFunctionsUtil.userDefinedFunctions;
@@ -64,6 +70,7 @@ public class CalculatedFieldCtx {
private String expression;
private boolean useLatestTs;
private TbelInvokeService tbelInvokeService;
+ private RelationService relationService;
private CalculatedFieldScriptEngine calculatedFieldScriptEngine;
private ThreadLocal customExpression;
@@ -73,31 +80,63 @@ public class CalculatedFieldCtx {
private long maxStateSize;
private long maxSingleValueArgumentSize;
- public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService) {
+ private boolean relationQueryDynamicArguments;
+ private List mainEntityGeofencingArgumentNames;
+ private List linkedEntityGeofencingArgumentNames;
+
+ private long scheduledUpdateIntervalMillis;
+
+ public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService, RelationService relationService) {
this.calculatedField = calculatedField;
this.cfId = calculatedField.getId();
this.tenantId = calculatedField.getTenantId();
this.entityId = calculatedField.getEntityId();
this.cfType = calculatedField.getType();
- CalculatedFieldConfiguration configuration = calculatedField.getConfiguration();
- this.arguments = configuration.getArguments();
+ this.arguments = new HashMap<>();
this.mainEntityArguments = new HashMap<>();
this.linkedEntityArguments = new HashMap<>();
- for (Map.Entry entry : arguments.entrySet()) {
- var refId = entry.getValue().getRefEntityId();
- var refKey = entry.getValue().getRefEntityKey();
- if (refId == null || refId.equals(calculatedField.getEntityId())) {
- mainEntityArguments.put(refKey, entry.getKey());
- } else {
- linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey());
+ this.argNames = new ArrayList<>();
+ this.mainEntityGeofencingArgumentNames = new ArrayList<>();
+ this.linkedEntityGeofencingArgumentNames = new ArrayList<>();
+ this.output = calculatedField.getConfiguration().getOutput();
+ if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argBasedConfig) {
+ this.arguments.putAll(argBasedConfig.getArguments());
+ for (Map.Entry entry : arguments.entrySet()) {
+ var refId = entry.getValue().getRefEntityId();
+ var refKey = entry.getValue().getRefEntityKey();
+ if (refId == null && entry.getValue().hasDynamicSource()) {
+ relationQueryDynamicArguments = true;
+ continue;
+ }
+ if (refId == null || refId.equals(calculatedField.getEntityId())) {
+ mainEntityArguments.put(refKey, entry.getKey());
+ } else {
+ linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey());
+ }
+ }
+ this.argNames.addAll(arguments.keySet());
+ if (argBasedConfig instanceof ExpressionBasedCalculatedFieldConfiguration expressionBasedConfig) {
+ this.expression = expressionBasedConfig.getExpression();
+ this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) argBasedConfig).isUseLatestTs();
}
+ if (calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration geofencingConfig) {
+ geofencingConfig.getZoneGroups().forEach((zoneGroupName, config) -> {
+ if (config.isCfEntitySource(entityId)) {
+ mainEntityGeofencingArgumentNames.add(zoneGroupName);
+ return;
+ }
+ if (config.isLinkedCfEntitySource(entityId)) {
+ linkedEntityGeofencingArgumentNames.add(zoneGroupName);
+ }
+ });
+ }
+ }
+ if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) {
+ this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L;
}
- this.argNames = new ArrayList<>(arguments.keySet());
- this.output = configuration.getOutput();
- this.expression = configuration.getExpression();
- this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) configuration).isUseLatestTs();
this.tbelInvokeService = tbelInvokeService;
+ this.relationService = relationService;
this.maxDataPointsPerRollingArg = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);
this.maxStateSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024;
@@ -105,25 +144,31 @@ public class CalculatedFieldCtx {
}
public void init() {
- if (CalculatedFieldType.SCRIPT.equals(cfType)) {
- try {
- this.calculatedFieldScriptEngine = initEngine(tenantId, expression, tbelInvokeService);
- initialized = true;
- } catch (Exception e) {
- throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e);
+ switch (cfType) {
+ case SCRIPT -> {
+ try {
+ this.calculatedFieldScriptEngine = initEngine(tenantId, expression, tbelInvokeService);
+ initialized = true;
+ } catch (Exception e) {
+ initialized = false;
+ throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e);
+ }
}
- } else {
- if (isValidExpression(expression)) {
- this.customExpression = ThreadLocal.withInitial(() ->
- new ExpressionBuilder(expression)
- .functions(userDefinedFunctions)
- .implicitMultiplication(true)
- .variables(this.arguments.keySet())
- .build()
- );
- initialized = true;
- } else {
- throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.");
+ case GEOFENCING -> initialized = true;
+ case SIMPLE -> {
+ if (isValidExpression(expression)) {
+ this.customExpression = ThreadLocal.withInitial(() ->
+ new ExpressionBuilder(expression)
+ .functions(userDefinedFunctions)
+ .implicitMultiplication(true)
+ .variables(this.arguments.keySet())
+ .build()
+ );
+ initialized = true;
+ } else {
+ initialized = false;
+ throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.");
+ }
}
}
}
@@ -293,15 +338,42 @@ public class CalculatedFieldCtx {
}
public boolean hasOtherSignificantChanges(CalculatedFieldCtx other) {
- boolean expressionChanged = !expression.equals(other.expression);
+ boolean expressionChanged = calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !expression.equals(other.expression);
boolean outputChanged = !output.equals(other.output);
- return expressionChanged || outputChanged;
+ boolean scheduledUpdatesConfigChanged = scheduledUpdateIntervalMillis != other.scheduledUpdateIntervalMillis;
+ return expressionChanged || outputChanged || scheduledUpdatesConfigChanged;
}
public boolean hasStateChanges(CalculatedFieldCtx other) {
boolean typeChanged = !cfType.equals(other.cfType);
boolean argumentsChanged = !arguments.equals(other.arguments);
- return typeChanged || argumentsChanged;
+ boolean geoZoneGroupsConfigChanged = hasGeofencingZoneGroupConfigurationChanges(other);
+ return typeChanged || argumentsChanged || geoZoneGroupsConfigChanged;
+ }
+
+ private boolean hasGeofencingZoneGroupConfigurationChanges(CalculatedFieldCtx other) {
+ if (calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration thisConfig
+ && other.calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration otherConfig) {
+ return !thisConfig.getZoneGroups().equals(otherConfig.getZoneGroups());
+ }
+ return false;
+ }
+
+ public boolean hasRelationQueryDynamicArguments() {
+ return relationQueryDynamicArguments && scheduledUpdateIntervalMillis != -1;
+ }
+
+ public boolean shouldFetchDynamicArgumentsFromDb(CalculatedFieldState state) {
+ if (!hasRelationQueryDynamicArguments()) {
+ return false;
+ }
+ if (!(state instanceof GeofencingCalculatedFieldState geofencingState)) {
+ return false;
+ }
+ if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) {
+ return true;
+ }
+ return geofencingState.getLastDynamicArgumentsRefreshTs() < System.currentTimeMillis() - scheduledUpdateIntervalMillis;
}
public String getSizeExceedsLimitMessage() {
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
index 0de354bbb0..3e0964bfd2 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
@@ -20,12 +20,17 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import java.util.List;
import java.util.Map;
+import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto;
+
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
@@ -34,6 +39,7 @@ import java.util.Map;
@JsonSubTypes({
@JsonSubTypes.Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"),
@JsonSubTypes.Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"),
+ @JsonSubTypes.Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"),
})
public interface CalculatedFieldState {
@@ -48,7 +54,7 @@ public interface CalculatedFieldState {
boolean updateState(CalculatedFieldCtx ctx, Map argumentValues);
- ListenableFuture performCalculation(CalculatedFieldCtx ctx);
+ ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx);
@JsonIgnore
boolean isReady();
@@ -62,6 +68,15 @@ public interface CalculatedFieldState {
void checkStateSize(CalculatedFieldEntityCtxId ctxId, long maxStateSize);
- void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx);
+ default void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) {
+ if (entry instanceof TsRollingArgumentEntry || entry instanceof GeofencingArgumentEntry) {
+ return;
+ }
+ if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
+ if (ctx.getMaxSingleValueArgumentSize() > 0 && toSingleValueArgumentProto(name, singleValueArgumentEntry).getSerializedSize() > ctx.getMaxSingleValueArgumentSize()) {
+ throw new IllegalArgumentException("Single value size exceeds the maximum allowed limit. The argument will not be used for calculation.");
+ }
+ }
+ }
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java
index 84dce627ae..fe7dfa04d0 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java
@@ -20,6 +20,7 @@ import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.Data;
+import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.script.api.tbel.TbelCfArg;
@@ -27,6 +28,7 @@ import org.thingsboard.script.api.tbel.TbelCfCtx;
import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Output;
+import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import java.util.ArrayList;
@@ -37,6 +39,7 @@ import java.util.Map;
@Data
@Slf4j
@NoArgsConstructor
+@EqualsAndHashCode(callSuper = true)
public class ScriptCalculatedFieldState extends BaseCalculatedFieldState {
public ScriptCalculatedFieldState(List requiredArguments) {
@@ -49,11 +52,7 @@ public class ScriptCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
- protected void validateNewEntry(ArgumentEntry newEntry) {
- }
-
- @Override
- public ListenableFuture performCalculation(CalculatedFieldCtx ctx) {
+ public ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx) {
Map arguments = new LinkedHashMap<>();
List args = new ArrayList<>(ctx.getArgNames().size() + 1);
args.add(new Object()); // first element is a ctx, but we will set it later;
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
index 577ff80219..5a437b52f3 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
@@ -20,11 +20,13 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.Data;
+import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Output;
+import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.BasicKvEntry;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
@@ -33,6 +35,7 @@ import java.util.Map;
@Data
@NoArgsConstructor
+@EqualsAndHashCode(callSuper = true)
public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
public SimpleCalculatedFieldState(List requiredArguments) {
@@ -45,14 +48,15 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
- protected void validateNewEntry(ArgumentEntry newEntry) {
+ protected void validateNewEntry(String key, ArgumentEntry newEntry) {
if (newEntry instanceof TsRollingArgumentEntry) {
- throw new IllegalArgumentException("Rolling argument entry is not supported for simple calculated fields.");
+ throw new IllegalArgumentException("Unsupported argument type detected for argument: " + key + ". " +
+ "Rolling argument entry is not supported for simple calculated fields.");
}
}
@Override
- public ListenableFuture performCalculation(CalculatedFieldCtx ctx) {
+ public ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx) {
var expr = ctx.getCustomExpression().get();
for (Map.Entry entry : this.arguments.entrySet()) {
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java
new file mode 100644
index 0000000000..53e5c19e72
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java
@@ -0,0 +1,111 @@
+/**
+ * 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.cf.ctx.state.geofencing;
+
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.script.api.tbel.TbelCfArg;
+import org.thingsboard.script.api.tbel.TbelCfTsGeofencingArg;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.kv.KvEntry;
+import org.thingsboard.server.common.util.ProtoUtils;
+import org.thingsboard.server.gen.transport.TransportProtos;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType;
+
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Data
+@Slf4j
+public class GeofencingArgumentEntry implements ArgumentEntry {
+
+ private Map zoneStates;
+
+ private boolean forceResetPrevious;
+
+ public GeofencingArgumentEntry() {
+ }
+
+ public GeofencingArgumentEntry(EntityId entityId, TransportProtos.AttributeValueProto entry) {
+ this(Map.of(entityId, ProtoUtils.fromProto(entry)));
+ }
+
+ public GeofencingArgumentEntry(Map entityIdkvEntryMap) {
+ this.zoneStates = toZones(entityIdkvEntryMap);
+ }
+
+ @Override
+ public ArgumentEntryType getType() {
+ return ArgumentEntryType.GEOFENCING;
+ }
+
+ @Override
+ public Object getValue() {
+ return zoneStates;
+ }
+
+ @Override
+ public boolean updateEntry(ArgumentEntry entry) {
+ if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {
+ throw new IllegalArgumentException("Unsupported argument entry type for geofencing argument entry: " + entry.getType());
+ }
+ if (geofencingArgumentEntry.isEmpty()) {
+ zoneStates.clear();
+ return true;
+ }
+ boolean updated = false;
+ for (var zoneEntry : geofencingArgumentEntry.getZoneStates().entrySet()) {
+ if (updateZone(zoneEntry)) {
+ updated = true;
+ }
+ }
+ return updated;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return zoneStates == null || zoneStates.isEmpty();
+ }
+
+ @Override
+ public TbelCfArg toTbelCfArg() {
+ return new TbelCfTsGeofencingArg(zoneStates);
+ }
+
+ private Map toZones(Map entityIdKvEntryMap) {
+ return entityIdKvEntryMap.entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey,
+ entry -> new GeofencingZoneState(entry.getKey(), entry.getValue())));
+ }
+
+ private boolean updateZone(Map.Entry zoneEntry) {
+ EntityId zoneId = zoneEntry.getKey();
+ GeofencingZoneState newZoneState = zoneEntry.getValue();
+
+ GeofencingZoneState existingZoneState = zoneStates.get(zoneId);
+ if (existingZoneState == null) {
+ zoneStates.put(zoneId, newZoneState);
+ return true;
+ }
+ if (newZoneState.getPerimeterDefinition() == null) {
+ zoneStates.remove(zoneId);
+ return true;
+ }
+ return existingZoneState.update(newZoneState);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java
new file mode 100644
index 0000000000..398de0c20b
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java
@@ -0,0 +1,188 @@
+/**
+ * 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.cf.ctx.state.geofencing;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.common.util.geo.Coordinates;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.OutputType;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.relation.EntityRelation;
+import org.thingsboard.server.service.cf.CalculatedFieldResult;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType;
+import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE;
+
+@Data
+@Slf4j
+@NoArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
+
+ private long lastDynamicArgumentsRefreshTs = -1;
+
+ public GeofencingCalculatedFieldState(List requiredArguments) {
+ super(requiredArguments);
+ }
+
+ @Override
+ public CalculatedFieldType getType() {
+ return CalculatedFieldType.GEOFENCING;
+ }
+
+ @Override
+ protected void validateNewEntry(String key, ArgumentEntry newEntry) {
+ switch (key) {
+ case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> {
+ if (!(newEntry instanceof SingleValueArgumentEntry)) {
+ throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
+ "Only SINGLE_VALUE type is allowed.");
+ }
+ }
+ default -> {
+ if (!(newEntry instanceof GeofencingArgumentEntry)) {
+ throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
+ "Only GEOFENCING type is allowed.");
+ }
+ }
+ }
+ }
+
+ @Override
+ public ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx) {
+ double latitude = (double) arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY).getValue();
+ double longitude = (double) arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY).getValue();
+ Coordinates entityCoordinates = new Coordinates(latitude, longitude);
+
+ var geofencingCfg = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
+ Map zoneGroups = geofencingCfg.getZoneGroups();
+
+ ObjectNode valuesNode = JacksonUtil.newObjectNode();
+ List> relationFutures = new ArrayList<>();
+
+ getGeofencingArguments().forEach((argumentKey, argumentEntry) -> {
+ ZoneGroupConfiguration zoneGroupCfg = zoneGroups.get(argumentKey);
+ if (zoneGroupCfg == null) {
+ throw new RuntimeException("Zone group configuration is missing for the: " + entityId);
+ }
+ boolean createRelationsWithMatchedZones = zoneGroupCfg.isCreateRelationsWithMatchedZones();
+ List zoneResults = new ArrayList<>(argumentEntry.getZoneStates().size());
+ argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> {
+ GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates);
+ zoneResults.add(eval);
+ if (createRelationsWithMatchedZones) {
+ GeofencingTransitionEvent transitionEvent = eval.transition();
+ if (transitionEvent == null) {
+ return;
+ }
+ EntityRelation relation = switch (zoneGroupCfg.getDirection()) {
+ case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType());
+ case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType());
+ };
+ ListenableFuture f = switch (transitionEvent) {
+ case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation);
+ case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation);
+ };
+ relationFutures.add(f);
+ }
+ });
+ updateValuesNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), valuesNode);
+ });
+
+ OutputType outputType = ctx.getOutput().getType();
+ var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), toResultNode(outputType, valuesNode));
+ if (relationFutures.isEmpty()) {
+ return Futures.immediateFuture(result);
+ }
+ return Futures.whenAllComplete(relationFutures).call(() -> result, MoreExecutors.directExecutor());
+ }
+
+ private Map getGeofencingArguments() {
+ return arguments.entrySet()
+ .stream()
+ .filter(entry -> entry.getValue().getType().equals(ArgumentEntryType.GEOFENCING))
+ .collect(Collectors.toMap(Map.Entry::getKey, entry -> (GeofencingArgumentEntry) entry.getValue()));
+ }
+
+ private void updateValuesNode(String argumentKey, List zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) {
+ GeofencingEvalResult aggregationResult = aggregateZoneGroup(zoneResults);
+ final String eventKey = argumentKey + "Event";
+ final String statusKey = argumentKey + "Status";
+ switch (geofencingReportStrategy) {
+ case REPORT_TRANSITION_EVENTS_ONLY -> addTransitionEventIfExists(resultNode, aggregationResult, eventKey);
+ case REPORT_PRESENCE_STATUS_ONLY -> resultNode.put(statusKey, aggregationResult.status().name());
+ case REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS -> {
+ addTransitionEventIfExists(resultNode, aggregationResult, eventKey);
+ resultNode.put(statusKey, aggregationResult.status().name());
+ }
+ }
+ }
+
+ private JsonNode toResultNode(OutputType outputType, ObjectNode valuesNode) {
+ if (OutputType.ATTRIBUTES.equals(outputType) || latestTimestamp == -1) {
+ return valuesNode;
+ }
+ ObjectNode resultNode = JacksonUtil.newObjectNode();
+ resultNode.put("ts", latestTimestamp);
+ resultNode.set("values", valuesNode);
+ return resultNode;
+ }
+
+ private GeofencingEvalResult aggregateZoneGroup(List zoneResults) {
+ boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status()));
+ boolean prevInside = zoneResults.stream()
+ .anyMatch(r -> GeofencingTransitionEvent.LEFT.equals(r.transition()) || r.transition() == null && r.status() == INSIDE);
+ GeofencingTransitionEvent transition = null;
+ if (!prevInside && nowInside) {
+ transition = GeofencingTransitionEvent.ENTERED;
+ } else if (prevInside && !nowInside) {
+ transition = GeofencingTransitionEvent.LEFT;
+ }
+ return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE);
+ }
+
+ private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) {
+ if (aggregationResult.transition() != null) {
+ resultNode.put(eventKey, aggregationResult.transition().name());
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java
new file mode 100644
index 0000000000..c6bf3dd65e
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.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.server.service.cf.ctx.state.geofencing;
+
+import jakarta.annotation.Nullable;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent;
+
+public record GeofencingEvalResult(@Nullable GeofencingTransitionEvent transition,
+ GeofencingPresenceStatus status) {
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java
new file mode 100644
index 0000000000..c849f5d169
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java
@@ -0,0 +1,106 @@
+/**
+ * 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.cf.ctx.state.geofencing;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.common.util.geo.Coordinates;
+import org.thingsboard.common.util.geo.PerimeterDefinition;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.kv.AttributeKvEntry;
+import org.thingsboard.server.common.data.kv.KvEntry;
+import org.thingsboard.server.common.util.ProtoUtils;
+import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto;
+
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE;
+
+@Data
+public class GeofencingZoneState {
+
+ private final EntityId zoneId;
+
+ private long ts;
+ private Long version;
+ private PerimeterDefinition perimeterDefinition;
+
+ @EqualsAndHashCode.Exclude
+ private GeofencingPresenceStatus lastPresence;
+
+ public GeofencingZoneState(EntityId zoneId, KvEntry entry) {
+ this.zoneId = zoneId;
+ if (!(entry instanceof AttributeKvEntry attributeKvEntry)) {
+ throw new IllegalArgumentException("Unsupported KvEntry type for geofencing zone state: " + entry.getClass().getSimpleName());
+ }
+ this.ts = attributeKvEntry.getLastUpdateTs();
+ this.version = attributeKvEntry.getVersion();
+ this.perimeterDefinition = JacksonUtil.fromString(entry.getValueAsString(), PerimeterDefinition.class);
+ }
+
+ public GeofencingZoneState(GeofencingZoneProto proto) {
+ this.zoneId = ProtoUtils.fromProto(proto.getZoneId());
+ this.ts = proto.getTs();
+ this.version = proto.getVersion();
+ this.perimeterDefinition = JacksonUtil.fromString(proto.getPerimeterDefinition(), PerimeterDefinition.class);
+ if (proto.hasInside()) {
+ this.lastPresence = proto.getInside() ? INSIDE : OUTSIDE;
+ }
+ }
+
+ public boolean update(GeofencingZoneState newZoneState) {
+ if (newZoneState.getTs() <= this.ts) {
+ return false;
+ }
+ Long newVersion = newZoneState.getVersion();
+ if (newVersion == null || this.version == null || newVersion > this.version) {
+ this.ts = newZoneState.getTs();
+ this.version = newVersion;
+ this.perimeterDefinition = newZoneState.getPerimeterDefinition();
+ this.lastPresence = null;
+ return true;
+ }
+ return false;
+ }
+
+ public GeofencingEvalResult evaluate(Coordinates entityCoordinates) {
+ boolean nowInside = perimeterDefinition.checkMatches(entityCoordinates);
+
+ GeofencingPresenceStatus status = nowInside ? INSIDE : OUTSIDE;
+
+ // first evaluation
+ if (this.lastPresence == null) {
+ this.lastPresence = status;
+ GeofencingTransitionEvent transition = null;
+ if (status == GeofencingPresenceStatus.INSIDE) {
+ transition = GeofencingTransitionEvent.ENTERED;
+ }
+ return new GeofencingEvalResult(transition, status);
+ }
+ // State changed
+ if (this.lastPresence != status) {
+ this.lastPresence = status;
+ GeofencingTransitionEvent transition = (status == GeofencingPresenceStatus.INSIDE) ?
+ GeofencingTransitionEvent.ENTERED : GeofencingTransitionEvent.LEFT;
+ return new GeofencingEvalResult(transition, status);
+ }
+ // State unchanged
+ return new GeofencingEvalResult(null, status);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
index 5c5eea32fd..c6bfef4971 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
@@ -24,6 +24,7 @@ import org.thingsboard.server.cache.limits.RateLimitService;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor;
+import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.alarm.AlarmService;
import org.thingsboard.server.dao.asset.AssetProfileService;
@@ -59,6 +60,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings;
import org.thingsboard.server.service.edge.rpc.EdgeRpcService;
import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor;
+import org.thingsboard.server.service.edge.rpc.processor.ai.AiModelProcessor;
import org.thingsboard.server.service.edge.rpc.processor.alarm.AlarmProcessor;
import org.thingsboard.server.service.edge.rpc.processor.alarm.comment.AlarmCommentProcessor;
import org.thingsboard.server.service.edge.rpc.processor.asset.AssetEdgeProcessor;
@@ -261,6 +263,11 @@ public class EdgeContextComponent {
@Autowired
private CalculatedFieldProcessor calculatedFieldProcessor;
+ @Autowired
+ private AiModelService aiModelService;
+ @Autowired
+ private AiModelProcessor aiModelProcessor;
+
public EdgeProcessor getProcessor(EdgeEventType edgeEventType) {
EdgeProcessor processor = processorMap.get(edgeEventType);
if (processor == null) {
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java
index fda9a1d17b..825b911403 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java
@@ -113,7 +113,7 @@ public class EdgeEventSourcingListener {
return;
}
try {
- if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL == entityType) {
+ if (EntityType.TENANT == entityType || EntityType.EDGE == entityType) {
return;
}
log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event);
@@ -227,7 +227,7 @@ public class EdgeEventSourcingListener {
break;
case TENANT:
return !event.getCreated();
- case API_USAGE_STATE, EDGE, AI_MODEL:
+ case API_USAGE_STATE, EDGE:
return false;
case DOMAIN:
if (entity instanceof Domain domain) {
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java
index 192c56692d..505b03a1cd 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java
@@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.asset.Asset;
@@ -52,6 +53,7 @@ import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.domain.DomainInfo;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEventActionType;
+import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
@@ -86,6 +88,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
+import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
@@ -654,4 +657,17 @@ public class EdgeMsgConstructorUtils {
.setIdLSB(calculatedFieldId.getId().getLeastSignificantBits()).build();
}
+ public static AiModelUpdateMsg constructAiModelUpdatedMsg(UpdateMsgType msgType, AiModel aiModel) {
+ return AiModelUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(aiModel))
+ .setIdMSB(aiModel.getId().getId().getMostSignificantBits())
+ .setIdLSB(aiModel.getId().getId().getLeastSignificantBits()).build();
+ }
+
+ public static AiModelUpdateMsg constructAiModelDeleteMsg(AiModelId aiModelId) {
+ return AiModelUpdateMsg.newBuilder()
+ .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE)
+ .setIdMSB(aiModelId.getId().getMostSignificantBits())
+ .setIdLSB(aiModelId.getId().getLeastSignificantBits()).build();
+ }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java
index 89d19438bd..618aee5e00 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java
@@ -29,4 +29,6 @@ public class EdgeEventStorageSettings {
private long noRecordsSleepInterval;
@Value("${edges.storage.sleep_between_batches}")
private long sleepIntervalBetweenBatches;
+ @Value("${edges.storage.misordering_compensation_millis:60000}")
+ private long misorderingCompensationMillis;
}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
index 7340696788..9fcb7425b2 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
@@ -93,14 +93,13 @@ import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAS
@TbCoreComponent
public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService {
- private static final int DESTROY_SESSION_MAX_ATTEMPTS = 10;
-
private final ConcurrentMap sessions = new ConcurrentHashMap<>();
private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>();
private final Map sessionNewEvents = new HashMap<>();
private final ConcurrentMap> sessionEdgeEventChecks = new ConcurrentHashMap<>();
private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>();
private final ConcurrentMap edgeEventsMigrationProcessed = new ConcurrentHashMap<>();
+ private final List zombieSessions = new ArrayList<>();
@Value("${edges.rpc.port}")
private int rpcPort;
@@ -193,7 +192,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler");
this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler");
this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service");
- this.executorService.scheduleAtFixedRate(this::destroyKafkaSessionIfDisconnectedAndConsumerActive, 60, 60, TimeUnit.SECONDS);
+ this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS);
log.info("Edge RPC service initialized!");
}
@@ -518,14 +517,10 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
private void destroySession(EdgeGrpcSession session) {
try (session) {
- for (int i = 0; i < DESTROY_SESSION_MAX_ATTEMPTS; i++) {
- if (session.destroy()) {
- break;
- } else {
- try {
- Thread.sleep(100);
- } catch (InterruptedException ignored) {}
- }
+ if (!session.destroy()) {
+ log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.",
+ session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId());
+ zombieSessions.add(session);
}
}
}
@@ -634,7 +629,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
}
}
- private void destroyKafkaSessionIfDisconnectedAndConsumerActive() {
+ private void cleanupZombieSessions() {
try {
List toRemove = new ArrayList<>();
for (EdgeGrpcSession session : sessions.values()) {
@@ -655,9 +650,19 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
}
}
}
+ zombieSessions.removeIf(zombie -> {
+ if (zombie.destroy()) {
+ log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].",
+ zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
+ return true;
+ } else {
+ log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].",
+ zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
+ return false;
+ }
+ });
} catch (Exception e) {
log.warn("Failed to cleanup kafka sessions", e);
}
}
-
}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
index 521730741f..e4297e9bfc 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
@@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.dao.edge.stats.EdgeStatsKey;
+import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
@@ -589,7 +590,8 @@ public abstract class EdgeGrpcSession implements Closeable {
previousStartSeqId,
false,
Integer.toUnsignedLong(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()),
- ctx.getEdgeEventService());
+ ctx.getEdgeEventService(),
+ ctx.getEdgeEventStorageSettings().getMisorderingCompensationMillis());
log.trace("[{}][{}] starting processing edge events, previousStartTs = {}, previousStartSeqId = {}",
tenantId, edge.getId(), previousStartTs, previousStartSeqId);
Futures.addCallback(startProcessingEdgeEvents(fetcher), new FutureCallback<>() {
@@ -933,6 +935,11 @@ public abstract class EdgeGrpcSession implements Closeable {
result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg));
}
}
+ if (uplinkMsg.getAiModelUpdateMsgCount() > 0) {
+ for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) {
+ result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg));
+ }
+ }
} catch (Exception e) {
String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg);
log.trace("[{}][{}] Can't process uplink msg [{}]", tenantId, edge.getId(), uplinkMsg, e);
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java
index 5d7df601b5..b4f894c422 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java
@@ -26,13 +26,9 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.dao.edge.EdgeEventService;
-import java.util.concurrent.TimeUnit;
-
@AllArgsConstructor
@Slf4j
public class GeneralEdgeEventFetcher implements EdgeEventFetcher {
- // Subtract from queueStartTs to ensure no data is lost due to potential misordering of edge events by created_time.
- private static final long MISORDERING_COMPENSATION_MILLIS = TimeUnit.SECONDS.toMillis(60);
private final Long queueStartTs;
private Long seqIdStart;
@@ -40,6 +36,10 @@ public class GeneralEdgeEventFetcher implements EdgeEventFetcher {
private boolean seqIdNewCycleStarted;
private Long maxReadRecordsCount;
private final EdgeEventService edgeEventService;
+ // Subtract from queueStartTs to compensate for possible misalignment between `created_time` and `seqId`.
+ // This ensures early events with lower seqId are not skipped due to partitioning by `created_time`.
+ // See: edge_event is partitioned by created_time but sorted by seqId during retrieval.
+ private final long misorderingCompensationMillis;
@Override
public PageLink getPageLink(int pageSize) {
@@ -48,7 +48,7 @@ public class GeneralEdgeEventFetcher implements EdgeEventFetcher {
0,
null,
null,
- queueStartTs > 0 ? queueStartTs - MISORDERING_COMPENSATION_MILLIS : 0,
+ queueStartTs > 0 ? queueStartTs - misorderingCompensationMillis : 0,
System.currentTimeMillis());
}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java
index a2243a88d2..d6bfeae0b7 100644
--- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java
@@ -139,7 +139,7 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor {
UPDATED_COMMENT, DELETED -> true;
default -> switch (type) {
case ALARM, ALARM_COMMENT, RULE_CHAIN, RULE_CHAIN_METADATA, USER, CUSTOMER, TENANT, TENANT_PROFILE,
- WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, NOTIFICATION_TEMPLATE,
+ WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, AI_MODEL, NOTIFICATION_TEMPLATE,
NOTIFICATION_TARGET, NOTIFICATION_RULE -> true;
default -> false;
};
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java
new file mode 100644
index 0000000000..74ca7ac27a
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java
@@ -0,0 +1,130 @@
+/**
+ * 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.edge.rpc.processor.ai;
+
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.util.Pair;
+import org.springframework.stereotype.Component;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.EdgeUtils;
+import org.thingsboard.server.common.data.ai.AiModel;
+import org.thingsboard.server.common.data.edge.Edge;
+import org.thingsboard.server.common.data.edge.EdgeEvent;
+import org.thingsboard.server.common.data.edge.EdgeEventActionType;
+import org.thingsboard.server.common.data.edge.EdgeEventType;
+import org.thingsboard.server.common.data.id.AiModelId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.msg.TbMsgType;
+import org.thingsboard.server.common.msg.TbMsgMetaData;
+import org.thingsboard.server.dao.exception.DataValidationException;
+import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
+import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
+import org.thingsboard.server.gen.edge.v1.EdgeVersion;
+import org.thingsboard.server.gen.edge.v1.UpdateMsgType;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
+
+import java.util.Optional;
+import java.util.UUID;
+
+@Slf4j
+@Component
+@TbCoreComponent
+public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiModelProcessor {
+
+ @Override
+ public ListenableFuture processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg) {
+ AiModelId aiModelId = new AiModelId(new UUID(aiModelUpdateMsg.getIdMSB(), aiModelUpdateMsg.getIdLSB()));
+ try {
+ edgeSynchronizationManager.getEdgeId().set(edge.getId());
+
+ switch (aiModelUpdateMsg.getMsgType()) {
+ case ENTITY_CREATED_RPC_MESSAGE:
+ case ENTITY_UPDATED_RPC_MESSAGE:
+ processAiModel(tenantId, aiModelId, aiModelUpdateMsg, edge);
+ return Futures.immediateFuture(null);
+ case UNRECOGNIZED:
+ default:
+ return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType());
+ }
+ } catch (DataValidationException e) {
+ return Futures.immediateFailedFuture(e);
+ } finally {
+ edgeSynchronizationManager.getEdgeId().remove();
+ }
+ }
+
+ @Override
+ public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) {
+ AiModelId aiModelId = new AiModelId(edgeEvent.getEntityId());
+ switch (edgeEvent.getAction()) {
+ case ADDED, UPDATED -> {
+ Optional aiModel = edgeCtx.getAiModelService().findAiModelById(edgeEvent.getTenantId(), aiModelId);
+ if (aiModel.isPresent()) {
+ UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction());
+ AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelUpdatedMsg(msgType, aiModel.get());
+ return DownlinkMsg.newBuilder()
+ .setDownlinkMsgId(EdgeUtils.nextPositiveInt())
+ .addAiModelUpdateMsg(aiModelUpdateMsg)
+ .build();
+ }
+ }
+ case DELETED -> {
+ AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelDeleteMsg(aiModelId);
+ return DownlinkMsg.newBuilder()
+ .setDownlinkMsgId(EdgeUtils.nextPositiveInt())
+ .addAiModelUpdateMsg(aiModelUpdateMsg)
+ .build();
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public EdgeEventType getEdgeEventType() {
+ return EdgeEventType.AI_MODEL;
+ }
+
+ private void processAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg, Edge edge) {
+ Pair resultPair = super.saveOrUpdateAiModel(tenantId, aiModelId, aiModelUpdateMsg);
+ Boolean wasCreated = resultPair.getFirst();
+ if (wasCreated) {
+ pushAiModelCreatedEventToRuleEngine(tenantId, edge, aiModelId);
+ }
+ Boolean nameWasUpdated = resultPair.getSecond();
+ if (nameWasUpdated) {
+ saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.AI_MODEL, EdgeEventActionType.UPDATED, aiModelId, null);
+ }
+ }
+
+ private void pushAiModelCreatedEventToRuleEngine(TenantId tenantId, Edge edge, AiModelId aiModelId) {
+ try {
+ Optional aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId);
+ if (aiModel.isPresent()) {
+ String aiModelAsString = JacksonUtil.toString(aiModel.get());
+ TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, edge.getCustomerId());
+ pushEntityEventToRuleEngine(tenantId, aiModelId, edge.getCustomerId(), TbMsgType.ENTITY_CREATED, aiModelAsString, msgMetaData);
+ } else {
+ log.warn("[{}][{}] Failed to find aiModel", tenantId, aiModelId);
+ }
+ } catch (Exception e) {
+ log.warn("[{}][{}] Failed to push aiModel action to rule engine: {}", tenantId, aiModelId, TbMsgType.ENTITY_CREATED.name(), e);
+ }
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java
new file mode 100644
index 0000000000..f66421167a
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java
@@ -0,0 +1,28 @@
+/**
+ * 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.edge.rpc.processor.ai;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.thingsboard.server.common.data.edge.Edge;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
+import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor;
+
+public interface AiModelProcessor extends EdgeProcessor {
+
+ ListenableFuture processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg);
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java
new file mode 100644
index 0000000000..cb1d27e0a1
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java
@@ -0,0 +1,81 @@
+/**
+ * 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.edge.rpc.processor.ai;
+
+import com.datastax.oss.driver.api.core.uuid.Uuids;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.util.Pair;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.ai.AiModel;
+import org.thingsboard.server.common.data.id.AiModelId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.dao.service.DataValidator;
+import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
+import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor;
+
+import java.util.Optional;
+
+@Slf4j
+public abstract class BaseAiModelProcessor extends BaseEdgeProcessor {
+
+ @Autowired
+ private DataValidator aiModelValidator;
+
+ protected Pair saveOrUpdateAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg) {
+ boolean isCreated = false;
+ boolean isNameUpdated = false;
+ try {
+ AiModel aiModel = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true);
+ if (aiModel == null) {
+ throw new RuntimeException("[{" + tenantId + "}] aiModelUpdateMsg {" + aiModelUpdateMsg + " } cannot be converted to aiModel");
+ }
+
+ Optional aiModelById = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId);
+ if (aiModelById.isEmpty()) {
+ aiModel.setCreatedTime(Uuids.unixTimestamp(aiModelId.getId()));
+ isCreated = true;
+ aiModel.setId(null);
+ } else {
+ aiModel.setId(aiModelId);
+ }
+
+ String aiModelName = aiModel.getName();
+ Optional aiModelByName = edgeCtx.getAiModelService().findAiModelByTenantIdAndName(aiModel.getTenantId(), aiModelName);
+ if (aiModelByName.isPresent() && !aiModelByName.get().getId().equals(aiModelId)) {
+ aiModelName = aiModelName + "_" + StringUtils.randomAlphabetic(15);
+ log.warn("[{}] aiModel with name {} already exists. Renaming aiModel name to {}",
+ tenantId, aiModel.getName(), aiModelByName.get().getName());
+ isNameUpdated = true;
+ }
+ aiModel.setName(aiModelName);
+
+ aiModelValidator.validate(aiModel, AiModel::getTenantId);
+
+ if (isCreated) {
+ aiModel.setId(aiModelId);
+ }
+
+ edgeCtx.getAiModelService().save(aiModel, false);
+ } catch (Exception e) {
+ log.error("[{}] Failed to process aiModel update msg [{}]", tenantId, aiModelUpdateMsg, e);
+ throw e;
+ }
+ return Pair.of(isCreated, isNameUpdated);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java
index 3f69d00276..ef630c2df4 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java
@@ -20,6 +20,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.audit.ActionType;
@@ -40,10 +41,15 @@ public class DefaultTbAssetService extends AbstractTbEntityService implements Tb
@Override
public Asset save(Asset asset, User user) throws Exception {
+ return save(asset, NameConflictStrategy.DEFAULT, user);
+ }
+
+ @Override
+ public Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception {
ActionType actionType = asset.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = asset.getTenantId();
try {
- Asset savedAsset = checkNotNull(assetService.saveAsset(asset));
+ Asset savedAsset = checkNotNull(assetService.saveAsset(asset, nameConflictStrategy));
autoCommit(user, savedAsset.getId());
logEntityActionService.logEntityAction(tenantId, savedAsset.getId(), savedAsset, asset.getCustomerId(),
actionType, user);
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java
index a2af8ffcdc..42fce5213e 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.service.entitiy.asset;
import org.thingsboard.server.common.data.Customer;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.edge.Edge;
@@ -27,6 +28,8 @@ public interface TbAssetService {
Asset save(Asset asset, User user) throws Exception;
+ Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception;
+
void delete(Asset asset, User user);
Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, Customer customer, User user) throws ThingsboardException;
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java
index c070aea087..a2a0e6846d 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java
@@ -19,6 +19,7 @@ import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.CustomerId;
@@ -32,10 +33,15 @@ public class DefaultTbCustomerService extends AbstractTbEntityService implements
@Override
public Customer save(Customer customer, SecurityUser user) throws Exception {
+ return save(customer, NameConflictStrategy.DEFAULT, user);
+ }
+
+ @Override
+ public Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception {
ActionType actionType = customer.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = customer.getTenantId();
try {
- Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer));
+ Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer, nameConflictStrategy));
autoCommit(user, savedCustomer.getId());
logEntityActionService.logEntityAction(tenantId, savedCustomer.getId(), savedCustomer, null, actionType, user);
return savedCustomer;
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java
index f77e990620..5884ae2e48 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java
@@ -16,8 +16,12 @@
package org.thingsboard.server.service.entitiy.customer;
import org.thingsboard.server.common.data.Customer;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.service.entitiy.SimpleTbEntityService;
+import org.thingsboard.server.service.security.model.SecurityUser;
public interface TbCustomerService extends SimpleTbEntityService {
+ Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception;
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java
index f182894239..e982adbdf1 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java
@@ -25,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
@@ -56,10 +57,15 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T
@Override
public Device save(Device device, String accessToken, User user) throws Exception {
+ return save(device, accessToken, NameConflictStrategy.DEFAULT, user);
+ }
+
+ @Override
+ public Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception {
ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = device.getTenantId();
try {
- Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken));
+ Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken, nameConflictStrategy));
autoCommit(user, savedDevice.getId());
logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(),
actionType, user);
@@ -73,10 +79,15 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T
@Override
public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, User user) throws ThingsboardException {
+ return saveDeviceWithCredentials(device, credentials, NameConflictStrategy.DEFAULT, user);
+ }
+
+ @Override
+ public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException {
ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = device.getTenantId();
try {
- Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials));
+ Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials, nameConflictStrategy));
logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(),
actionType, user);
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java
index c234b3b597..e217d8de7b 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java
@@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.device;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.edge.Edge;
@@ -33,8 +34,12 @@ public interface TbDeviceService {
Device save(Device device, String accessToken, User user) throws Exception;
+ Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception;
+
Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException;
+ Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException;
+
void delete(Device device, User user);
Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, Customer customer, User user) throws ThingsboardException;
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java
index 8fb99bfb0e..54699e0995 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java
@@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.EntityView;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.edge.Edge;
@@ -80,10 +81,15 @@ public class DefaultTbEntityViewService extends AbstractTbEntityService implemen
@Override
public EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception {
+ return save(entityView, existingEntityView, NameConflictStrategy.DEFAULT, user);
+ }
+
+ @Override
+ public EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception {
ActionType actionType = entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = entityView.getTenantId();
try {
- EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView));
+ EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView, nameConflictStrategy));
this.updateEntityViewAttributes(tenantId, savedEntityView, existingEntityView, user);
autoCommit(user, savedEntityView.getId());
logEntityActionService.logEntityAction(savedEntityView.getTenantId(), savedEntityView.getId(), savedEntityView,
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java
index 3aec924f75..bb4bcf9cad 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java
@@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.entityview;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntityView;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@@ -33,6 +34,8 @@ public interface TbEntityViewService extends ComponentLifecycleListener {
EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception;
+ EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception;
+
void updateEntityViewAttributes(TenantId tenantId, EntityView savedEntityView, EntityView oldEntityView, User user) throws ThingsboardException;
void delete(EntityView entity, User user) throws ThingsboardException;
diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
index f41a530630..f48f4621ef 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
@@ -32,7 +32,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
// This list should include all versions which are compatible for the upgrade.
// The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release.
- private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.0");
+ private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1");
private final ProjectInfo projectInfo;
private final JdbcTemplate jdbcTemplate;
diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
index 45a73072f5..f39769526a 100644
--- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
+++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
@@ -238,7 +238,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
entitiesSortOrder = sortOrder;
}
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
- return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters());
+ return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters());
}
@Override
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
index 8d4ab25578..acb36449e8 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
@@ -35,6 +35,7 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
@@ -83,6 +84,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
ActorSystemContext actorContext,
TbDeviceProfileCache deviceProfileCache,
TbAssetProfileCache assetProfileCache,
+ TbResourceDataCache tbResourceDataCache,
TbTenantProfileCache tenantProfileCache,
TbApiUsageStateService apiUsageStateService,
PartitionService partitionService,
@@ -90,7 +92,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
JwtSettingsService jwtSettingsService,
CalculatedFieldCache calculatedFieldCache,
CalculatedFieldStateService stateService) {
- super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService,
+ super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService,
eventPublisher, jwtSettingsService);
this.queueFactory = tbQueueFactory;
this.stateService = stateService;
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
index 265f14c4e2..86d00c77ca 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
@@ -48,6 +48,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId;
+import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.page.PageData;
@@ -435,8 +436,9 @@ public class DefaultTbClusterService implements TbClusterService {
@Override
public void onResourceChange(TbResourceInfo resource, TbQueueCallback callback) {
+ TenantId tenantId = resource.getTenantId();
+ TbResourceId resourceId = resource.getId();
if (resource.getResourceType() == ResourceType.LWM2M_MODEL) {
- TenantId tenantId = resource.getTenantId();
log.trace("[{}][{}][{}] Processing change resource", tenantId, resource.getResourceType(), resource.getResourceKey());
ResourceUpdateMsg resourceUpdateMsg = ResourceUpdateMsg.newBuilder()
.setTenantIdMSB(tenantId.getId().getMostSignificantBits())
@@ -447,6 +449,7 @@ public class DefaultTbClusterService implements TbClusterService {
ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceUpdateMsg(resourceUpdateMsg).build();
broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback);
}
+ broadcastEntityStateChangeEvent(tenantId, resourceId, ComponentLifecycleEvent.UPDATED);
}
@Override
@@ -462,6 +465,7 @@ public class DefaultTbClusterService implements TbClusterService {
ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceDeleteMsg(resourceDeleteMsg).build();
broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback);
}
+ broadcastEntityStateChangeEvent(resource.getTenantId(), resource.getId(), ComponentLifecycleEvent.DELETED);
}
private void broadcastEntityChangeToTransport(TenantId tenantId, EntityId entityid, T entity, TbQueueCallback callback) {
@@ -592,7 +596,8 @@ public class DefaultTbClusterService implements TbClusterService {
EntityType.TENANT_PROFILE,
EntityType.DEVICE_PROFILE,
EntityType.ASSET_PROFILE,
- EntityType.JOB)
+ EntityType.JOB,
+ EntityType.TB_RESOURCE)
|| (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
|| (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
) {
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
index f0b1a4d7d2..9ab5a062eb 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
@@ -55,6 +55,7 @@ import org.thingsboard.server.common.stats.StatsFactory;
import org.thingsboard.server.common.util.KvProtoUtil;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.resource.ImageCacheKey;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.DeviceStateServiceMsgProto;
@@ -176,10 +177,11 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService calculatedFields = calculatedFieldService.findCalculatedFieldsByEntityId(ctx.getTenantId(), entityId);
calculatedFields.forEach(calculatedField -> {
calculatedField.setEntityId(getExternalIdOrElseInternal(ctx, entityId));
- calculatedField.getConfiguration().getArguments().values().forEach(argument -> {
- if (argument.getRefEntityId() != null) {
- argument.setRefEntityId(getExternalIdOrElseInternal(ctx, argument.getRefEntityId()));
- }
- });
+ if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) {
+ configuration.getArguments().values().forEach(argument -> {
+ if (argument.getRefEntityId() != null) {
+ argument.setRefEntityId(getExternalIdOrElseInternal(ctx, argument.getRefEntityId()));
+ }
+ });
+ }
});
return calculatedFields;
}
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java
index 92fdcb09c4..2b606a2fe8 100644
--- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java
+++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java
@@ -35,6 +35,7 @@ import org.thingsboard.server.common.data.HasVersion;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
@@ -321,11 +322,13 @@ public abstract class BaseEntityImportService {
calculatedField.setTenantId(ctx.getTenantId());
calculatedField.setEntityId(savedEntity.getId());
- calculatedField.getConfiguration().getArguments().values().forEach(argument -> {
- if (argument.getRefEntityId() != null) {
- argument.setRefEntityId(idProvider.getInternalId(argument.getRefEntityId(), ctx.isFinalImportAttempt()));
- }
- });
+ if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) {
+ configuration.getArguments().values().forEach(argument -> {
+ if (argument.getRefEntityId() != null) {
+ argument.setRefEntityId(idProvider.getInternalId(argument.getRefEntityId(), ctx.isFinalImportAttempt()));
+ }
+ });
+ }
}).toList();
for (CalculatedField existingField : existing) {
diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
new file mode 100644
index 0000000000..934eadd98f
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
@@ -0,0 +1,75 @@
+/**
+ * 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.utils;
+
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import org.apache.commons.lang3.math.NumberUtils;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.kv.BooleanDataEntry;
+import org.thingsboard.server.common.data.kv.DoubleDataEntry;
+import org.thingsboard.server.common.data.kv.KvEntry;
+import org.thingsboard.server.common.data.kv.StringDataEntry;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
+
+import java.util.Optional;
+
+public class CalculatedFieldArgumentUtils {
+
+ public static ListenableFuture transformSingleValueArgument(ListenableFuture> kvEntryFuture) {
+ return Futures.transform(kvEntryFuture, CalculatedFieldArgumentUtils::transformSingleValueArgument, MoreExecutors.directExecutor());
+ }
+
+ public static ArgumentEntry transformSingleValueArgument(Optional extends KvEntry> kvEntry) {
+ if (kvEntry.isPresent() && kvEntry.get().getValue() != null) {
+ return ArgumentEntry.createSingleValueArgument(kvEntry.get());
+ } else {
+ return new SingleValueArgumentEntry();
+ }
+ }
+
+ public static KvEntry createDefaultKvEntry(Argument argument) {
+ String key = argument.getRefEntityKey().getKey();
+ String defaultValue = argument.getDefaultValue();
+ if (StringUtils.isBlank(defaultValue)) {
+ return new StringDataEntry(key, null);
+ }
+ if (NumberUtils.isParsable(defaultValue)) {
+ return new DoubleDataEntry(key, Double.parseDouble(defaultValue));
+ }
+ if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) {
+ return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue));
+ }
+ return new StringDataEntry(key, defaultValue);
+ }
+
+ public static CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) {
+ return switch (ctx.getCfType()) {
+ case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames());
+ case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames());
+ case GEOFENCING -> new GeofencingCalculatedFieldState(ctx.getArgNames());
+ };
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
index 77080c28c8..4e93c8233e 100644
--- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
+++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
@@ -15,31 +15,43 @@
*/
package org.thingsboard.server.utils;
+import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BasicKvEntry;
import org.thingsboard.server.common.util.KvProtoUtil;
+import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
+import org.thingsboard.server.gen.transport.TransportProtos.GeofencingArgumentProto;
+import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto;
import org.thingsboard.server.gen.transport.TransportProtos.SingleValueArgumentProto;
import org.thingsboard.server.gen.transport.TransportProtos.TsDoubleValProto;
import org.thingsboard.server.gen.transport.TransportProtos.TsRollingArgumentProto;
import org.thingsboard.server.gen.transport.TransportProtos.TsValueProto;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry;
+import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
public class CalculatedFieldUtils {
@@ -79,6 +91,8 @@ public class CalculatedFieldUtils {
builder.addSingleValueArguments(toSingleValueArgumentProto(argName, singleValueArgumentEntry));
} else if (argEntry instanceof TsRollingArgumentEntry rollingArgumentEntry) {
builder.addRollingValueArguments(toRollingArgumentProto(argName, rollingArgumentEntry));
+ } else if (argEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry) {
+ builder.addGeofencingArguments(toGeofencingArgumentProto(argName, geofencingArgumentEntry));
}
});
return builder.build();
@@ -108,6 +122,27 @@ public class CalculatedFieldUtils {
return builder.build();
}
+ private static GeofencingArgumentProto toGeofencingArgumentProto(String argName, GeofencingArgumentEntry geofencingArgumentEntry) {
+ Map zoneStates = geofencingArgumentEntry.getZoneStates();
+ GeofencingArgumentProto.Builder builder = GeofencingArgumentProto.newBuilder()
+ .setArgName(argName);
+ zoneStates.forEach((entityId, zoneState) ->
+ builder.addZones(toGeofencingZoneProto(entityId, zoneState)));
+ return builder.build();
+ }
+
+ private static GeofencingZoneProto toGeofencingZoneProto(EntityId entityId, GeofencingZoneState zoneState) {
+ GeofencingZoneProto.Builder builder = GeofencingZoneProto.newBuilder()
+ .setZoneId(ProtoUtils.toProto(entityId))
+ .setTs(zoneState.getTs())
+ .setVersion(zoneState.getVersion())
+ .setPerimeterDefinition(JacksonUtil.toString(zoneState.getPerimeterDefinition()));
+ if (zoneState.getLastPresence() != null) {
+ builder.setInside(zoneState.getLastPresence().equals(GeofencingPresenceStatus.INSIDE));
+ }
+ return builder.build();
+ }
+
public static CalculatedFieldState fromProto(CalculatedFieldStateProto proto) {
if (StringUtils.isEmpty(proto.getType())) {
return null;
@@ -118,6 +153,7 @@ public class CalculatedFieldUtils {
CalculatedFieldState state = switch (type) {
case SIMPLE -> new SimpleCalculatedFieldState();
case SCRIPT -> new ScriptCalculatedFieldState();
+ case GEOFENCING -> new GeofencingCalculatedFieldState();
};
proto.getSingleValueArgumentsList().forEach(argProto ->
@@ -128,6 +164,11 @@ public class CalculatedFieldUtils {
state.getArguments().put(argProto.getKey(), fromRollingArgumentProto(argProto)));
}
+ if (CalculatedFieldType.GEOFENCING.equals(type)) {
+ proto.getGeofencingArgumentsList().forEach(argProto ->
+ state.getArguments().put(argProto.getArgName(), fromGeofencingArgumentProto(argProto)));
+ }
+
return state;
}
@@ -149,4 +190,15 @@ public class CalculatedFieldUtils {
return new TsRollingArgumentEntry(tsRecords, proto.getLimit(), proto.getTimeWindow());
}
+
+ private static ArgumentEntry fromGeofencingArgumentProto(GeofencingArgumentProto proto) {
+ Map zoneStates = proto.getZonesList()
+ .stream()
+ .map(GeofencingZoneState::new)
+ .collect(Collectors.toMap(GeofencingZoneState::getZoneId, Function.identity()));
+ GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry();
+ geofencingArgumentEntry.setZoneStates(zoneStates);
+ return geofencingArgumentEntry;
+ }
+
}
diff --git a/application/src/main/resources/logback.xml b/application/src/main/resources/logback.xml
index c37be2e620..8e1a49faef 100644
--- a/application/src/main/resources/logback.xml
+++ b/application/src/main/resources/logback.xml
@@ -56,6 +56,9 @@
+
+
+
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index 54c1442ea7..2f3b40bf12 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -100,6 +100,9 @@ server:
rate_limits:
# Limit that prohibits resetting the password for the user too often. The value of the rate limit. By default, no more than 5 requests per hour
reset_password_per_user: "${RESET_PASSWORD_PER_USER_RATE_LIMIT_CONFIGURATION:5:3600}"
+ rule_engine:
+ # Default timeout for waiting response of REST API request to Rule Engine in milliseconds
+ response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}"
# Application info parameters
app:
@@ -303,8 +306,11 @@ cassandra:
default_fetch_size: "${CASSANDRA_DEFAULT_FETCH_SIZE:2000}"
# Specify partitioning size for timestamp key-value storage. Example: MINUTES, HOURS, DAYS, MONTHS, INDEFINITE
ts_key_value_partitioning: "${TS_KV_PARTITIONING:MONTHS}"
- # Enable/Disable timestamp key-value partioning on read queries
+ # Enable/Disable timestamp key-value partitioning on read queries
use_ts_key_value_partitioning_on_read: "${USE_TS_KV_PARTITIONING_ON_READ:true}"
+ # Safety trigger to fall back to use_ts_key_value_partitioning_on_read as true if estimated partitions count is greater than safety trigger value.
+ # It helps to prevent building huge partition list (OOM) for corner cases (like from 0 to infinity) and prefer fewer reads strategy from NoSQL database
+ use_ts_key_value_partitioning_on_read_max_estimated_partition_count: "${USE_TS_KV_PARTITIONING_ON_READ_MAX_ESTIMATED_PARTITION_COUNT:40}"
# The number of partitions that are cached in memory of each service. It is useful to decrease the load of re-inserting the same partitions again
ts_key_value_partitions_max_cache_size: "${TS_KV_PARTITIONS_MAX_CACHE_SIZE:100000}"
# Timeseries Time To Live (in seconds) for Cassandra Record. 0 - record has never expired
@@ -676,6 +682,9 @@ cache:
maxSize: "${CACHE_SPECS_IMAGE_ETAGS_MAX_SIZE:10000}" # 0 means the cache is disabled
systemImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_SYSTEM_BROWSER_TTL:0}" # Browser cache TTL for system images in minutes. 0 means the cache is disabled
tenantImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_TENANT_BROWSER_TTL:0}" # Browser cache TTL for tenant images in minutes. 0 means the cache is disabled
+ tbResourceData:
+ timeToLiveInMinutes: "${CACHE_SPECS_RESOURCE_DATA_TTL:10080}" # TB resource data cache TTL
+ maxSize: "${CACHE_SPECS_RESOURCE_DATA_MAX_SIZE:100000}" # 0 means the cache is disabled
# Spring data parameters
spring.data.redis.repositories.enabled: false # Disable this because it is not required.
@@ -1480,6 +1489,11 @@ edges:
no_read_records_sleep: "${EDGES_NO_READ_RECORDS_SLEEP:1000}"
# Number of milliseconds to wait before resending failed batch of edge events to edge
sleep_between_batches: "${EDGES_SLEEP_BETWEEN_BATCHES:60000}"
+ # Time (in milliseconds) to subtract from the start timestamp when fetching edge events.
+ # This compensates for possible misordering between `created_time` (used for partitioning)
+ # and `seqId` (used for sorting). Without this, events with smaller seqId but larger created_time
+ # might be skipped, especially across partition boundaries.
+ misordering_compensation_millis: "${EDGES_MISORDERING_COMPENSATION_MILLIS:60000}"
# Max number of high priority edge events per edge session. No persistence - stored in memory
max_high_priority_queue_size_per_session: "${EDGES_MAX_HIGH_PRIORITY_QUEUE_SIZE_PER_SESSION:10000}"
# Number of threads that are used to check DB for edge events
@@ -1534,6 +1548,8 @@ swagger:
version: "${SWAGGER_VERSION:}"
# The group name (definition) on the API doc UI page.
group_name: "${SWAGGER_GROUP_NAME:thingsboard}"
+ # Control the initial display state of API operations and tags (none or list)
+ doc_expansion: "${SWAGGER_DOC_EXPANSION:list}"
# Queue configuration parameters
queue:
diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
index c8b8b0244b..232bcf9445 100644
--- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
+++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
@@ -15,6 +15,7 @@
*/
package org.thingsboard.server.cf;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
@@ -23,28 +24,44 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.EntityInfo;
+import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.relation.EntityRelation;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.controller.CalculatedFieldControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@DaoSqlTest
public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTest {
@@ -121,7 +138,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("86.0");
});
- Argument savedArgument = savedCalculatedField.getConfiguration().getArguments().get("T");
+ Argument savedArgument = ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).getArguments().get("T");
savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
@@ -133,7 +150,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("104.0");
});
- savedCalculatedField.getConfiguration().setExpression("1.8 * T + 32");
+ ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).setExpression("1.8 * T + 32");
savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
await().alias("update CF expression -> perform calculation with new expression").atMost(TIMEOUT, TimeUnit.SECONDS)
@@ -606,6 +623,385 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
}
+ @Test
+ public void testSimpleCalculatedFieldWhenCtxBecameUninitialized() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+
+ CalculatedField calculatedField = new CalculatedField();
+ calculatedField.setEntityId(testDevice.getId());
+ calculatedField.setType(CalculatedFieldType.SIMPLE);
+ calculatedField.setName("M + 1");
+ calculatedField.setDebugSettings(DebugSettings.all());
+
+ SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
+
+ Argument argument = new Argument();
+ ReferencedEntityKey refEntityKey = new ReferencedEntityKey("m", ArgumentType.TS_LATEST, null);
+ argument.setRefEntityKey(refEntityKey);
+ config.setArguments(Map.of("m", argument));
+ config.setExpression("m + 1");
+
+ Output output = new Output();
+ output.setName("m1");
+ output.setType(OutputType.TIME_SERIES);
+ output.setDecimalsByDefault(0);
+ config.setOutput(output);
+
+ calculatedField.setConfiguration(config);
+
+ calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
+
+ doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":1}"));
+
+ await().alias("create CF -> ctx is initialized -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode m1 = getLatestTelemetry(testDevice.getId(), "m1");
+ assertThat(m1).isNotNull();
+ assertThat(m1.get("m1").get(0).get("value").asText()).isEqualTo("2");
+ });
+
+ config.setExpression("m m");
+ calculatedField.setConfiguration(config);
+ calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
+
+ doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":2}"));
+
+ await().alias("update CF -> ctx is not initialized -> no calculation performed").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ObjectNode m1 = getLatestTelemetry(testDevice.getId(), "m1");
+ assertThat(m1).isNotNull();
+ assertThat(m1.get("m1").get(0).get("value").asText()).isEqualTo("2");
+ });
+ }
+
+ @Test
+ public void testGeofencingCalculatedField_withZonesCreatedOnDevice() throws Exception {
+ // --- Arrange entities ---
+ Device device = createDevice("GF Test Device", "sn-geo-2");
+
+ // Allowed zone polygon (square)
+ String allowedPolygon = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
+ // Restricted zone polygon (square)
+ String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]";
+
+ doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
+ JacksonUtil.toJsonNode("{\"allowedZone\":" + allowedPolygon + "}")).andExpect(status().isOk());
+
+ doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
+ JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
+
+ // Initial device coordinates (inside Allowed, outside Restricted)
+ doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
+ JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}"));
+
+ // --- Build CF: GEOFENCING ---
+ CalculatedField cf = new CalculatedField();
+ cf.setEntityId(device.getDeviceProfileId());
+ cf.setType(CalculatedFieldType.GEOFENCING);
+ cf.setName("Geofencing CF");
+ cf.setDebugSettings(DebugSettings.off());
+
+ GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
+
+ // Coordinates: TS_LATEST on the device
+ EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
+ cfg.setEntityCoordinates(entityCoordinates);
+
+ // Zone groups: ATTRIBUTE on the device
+ ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("allowedZone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("restrictedZone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+
+ cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup));
+
+ // Output to server attributes
+ Output out = new Output();
+ out.setType(OutputType.ATTRIBUTES);
+ out.setScope(AttributeScope.SERVER_SCOPE);
+ cfg.setOutput(out);
+
+ cf.setConfiguration(cfg);
+
+ doPost("/api/calculatedField", cf, CalculatedField.class);
+
+ // --- Assert initial evaluation (ENTERED / OUTSIDE) ---
+ await().alias("initial geofencing evaluation")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(),
+ "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus", "restrictedZonesEvent");
+ // --- no restrictedZonesEvent as no transition happened yet
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(3);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesEvent", "ENTERED")
+ .containsEntry("allowedZonesStatus", "INSIDE")
+ .containsEntry("restrictedZonesStatus", "OUTSIDE");
+ });
+
+ // --- delete attributes reported in previous evaluation
+ doDelete("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/SERVER_SCOPE?keys=allowedZonesEvent,allowedZonesStatus,restrictedZonesStatus", String.class);
+
+ // --- Update restrictedZone by 'restrictedZone' attribute update
+ doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
+ JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
+
+ // --- Assert no transition ---
+ // --- Assert attributes updated with the same values for restrictedZones ---
+ // --- Assert attributes updated with the new values for allowedZones ---
+ await().alias("evaluation after version bump of geo argument")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(),
+ "allowedZonesEvent", "allowedZonesStatus",
+ "restrictedZonesEvent", "restrictedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesStatus", "INSIDE")
+ .containsEntry("restrictedZonesStatus", "OUTSIDE");
+ });
+ }
+
+ @Test
+ public void testGeofencingCalculatedField_withoutRelationsCreationAndDynamicRefresh() throws Exception {
+ // --- Arrange entities ---
+ Device device = createDevice("GF Device", "sn-geo-1");
+
+ // Allowed zone polygon (square)
+ String allowedPolygon = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
+ // Restricted zone polygon (square)
+ String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]";
+
+ Asset allowedZoneAsset = createAsset("Allowed Zone", null);
+ doPost("/api/plugins/telemetry/ASSET/" + allowedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
+ JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygon + "}")).andExpect(status().isOk());
+
+ Asset restrictedZoneAsset = createAsset("Restricted Zone", null);
+ doPost("/api/plugins/telemetry/ASSET/" + restrictedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
+ JacksonUtil.toJsonNode("{\"zone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
+
+ // Relations from device to zones
+ EntityRelation deviceToAllowedZoneRelation = new EntityRelation();
+ deviceToAllowedZoneRelation.setFrom(device.getId());
+ deviceToAllowedZoneRelation.setTo(allowedZoneAsset.getId());
+ deviceToAllowedZoneRelation.setType("AllowedZone");
+
+ EntityRelation deviceToRestrictedZoneRelation = new EntityRelation();
+ deviceToRestrictedZoneRelation.setFrom(device.getId());
+ deviceToRestrictedZoneRelation.setTo(restrictedZoneAsset.getId());
+ deviceToRestrictedZoneRelation.setType("RestrictedZone");
+
+ doPost("/api/relation", deviceToAllowedZoneRelation).andExpect(status().isOk());
+ doPost("/api/relation", deviceToRestrictedZoneRelation).andExpect(status().isOk());
+
+ // Initial device coordinates (inside Allowed, outside Restricted)
+ doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
+ JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}"));
+
+ // --- Build CF: GEOFENCING ---
+ CalculatedField cf = new CalculatedField();
+ cf.setEntityId(device.getId());
+ cf.setType(CalculatedFieldType.GEOFENCING);
+ cf.setName("Geofencing CF");
+ cf.setDebugSettings(DebugSettings.off());
+
+ GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
+
+ // Coordinates: TS_LATEST on the device
+ EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
+ cfg.setEntityCoordinates(entityCoordinates);
+
+ // Zone groups: ATTRIBUTE on specific assets (one zone per group)
+ ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone")));
+ allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
+
+ ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "RestrictedZone")));
+ restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration);
+
+ cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup));
+
+ // Output to server attributes
+ Output out = new Output();
+ out.setType(OutputType.ATTRIBUTES);
+ out.setScope(AttributeScope.SERVER_SCOPE);
+ cfg.setOutput(out);
+
+ cf.setConfiguration(cfg);
+
+ doPost("/api/calculatedField", cf, CalculatedField.class);
+
+ // --- Assert initial evaluation (ENTERED / OUTSIDE) ---
+ await().alias("initial geofencing evaluation")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(),
+ "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(3);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesEvent", "ENTERED")
+ .containsEntry("allowedZonesStatus", "INSIDE")
+ .containsEntry("restrictedZonesStatus", "OUTSIDE");
+ });
+
+ // --- Move the device into Restricted zone (and outside Allowed) ---
+ doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
+ JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}"));
+
+ // --- Assert transition (LEFT / ENTERED) ---
+ await().alias("transition evaluation after movement")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(),
+ "allowedZonesEvent", "allowedZonesStatus",
+ "restrictedZonesEvent", "restrictedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(4);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesEvent", "LEFT")
+ .containsEntry("restrictedZonesEvent", "ENTERED")
+ .containsEntry("allowedZonesStatus", "OUTSIDE")
+ .containsEntry("restrictedZonesStatus", "INSIDE");
+ });
+ }
+
+ @Test
+ public void testGeofencingCalculatedField_DynamicRefresh_RebindsZoneArguments() throws Exception {
+ // --- Update min allowed scheduled update intervals for CFs ---
+ loginSysAdmin();
+ EntityInfo tenantProfileEntityInfo = doGet("/api/tenantProfileInfo/default", EntityInfo.class);
+ assertThat(tenantProfileEntityInfo).isNotNull();
+ TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + tenantProfileEntityInfo.getId().getId().toString(), TenantProfile.class);
+ assertThat(foundTenantProfile).isNotNull();
+ assertThat(foundTenantProfile.getDefaultProfileConfiguration()).isNotNull();
+ int minAllowedScheduledUpdateIntervalInSecForCF = TIMEOUT / 10;
+ foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(minAllowedScheduledUpdateIntervalInSecForCF);
+ TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class);
+ assertThat(savedTenantProfile).isNotNull();
+ assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(minAllowedScheduledUpdateIntervalInSecForCF);
+ loginTenantAdmin();
+
+ // --- Arrange entities ---
+ Device device = createDevice("GF Device dyn", "sn-geo-dyn-1");
+
+ // Allowed Zone A: covers initial point (ENTERED)
+ String allowedPolygonA = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
+
+ Asset allowedZoneA = createAsset("Allowed Zone A", null);
+ doPost("/api/plugins/telemetry/ASSET/" + allowedZoneA.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
+ JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonA + "}")).andExpect(status().isOk());
+
+ // Relation from device to Allowed Zone A
+ EntityRelation relAllowedA = new EntityRelation();
+ relAllowedA.setFrom(device.getId());
+ relAllowedA.setTo(allowedZoneA.getId());
+ relAllowedA.setType("AllowedZone");
+ doPost("/api/relation", relAllowedA).andExpect(status().isOk());
+
+ // Initial device coordinates: INSIDE Zone A
+ doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
+ JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}")).andExpect(status().isOk());
+
+ // --- Build CF: GEOFENCING with dynamic 'allowedZones' and short scheduled refresh ---
+ CalculatedField cf = new CalculatedField();
+ cf.setEntityId(device.getId());
+ cf.setType(CalculatedFieldType.GEOFENCING);
+ cf.setName("Geofencing CF (dynamic refresh)");
+ cf.setDebugSettings(DebugSettings.off());
+
+ GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
+ cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
+
+ var allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone")));
+ allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
+ cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup));
+
+ // Server attributes output
+ Output out = new Output();
+ out.setType(OutputType.ATTRIBUTES);
+ out.setScope(AttributeScope.SERVER_SCOPE);
+ cfg.setOutput(out);
+
+ // Enable scheduled refresh with a 6-second interval
+ cfg.setScheduledUpdateInterval(minAllowedScheduledUpdateIntervalInSecForCF);
+ cfg.setScheduledUpdateEnabled(true);
+
+ cf.setConfiguration(cfg);
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class);
+ assertThat(savedCalculatedField).isNotNull();
+ CalculatedFieldConfiguration configuration = savedCalculatedField.getConfiguration();
+ assertThat(configuration).isInstanceOf(GeofencingCalculatedFieldConfiguration.class);
+ var geofencingConfiguration = (GeofencingCalculatedFieldConfiguration) configuration;
+ assertThat(geofencingConfiguration.isScheduledUpdateEnabled()).isTrue();
+
+ // --- Assert initial evaluation (ENTERED) ---
+ await().alias("initial geofencing evaluation")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesEvent", "ENTERED")
+ .containsEntry("allowedZonesStatus", "INSIDE");
+ });
+
+ // --- Move device OUTSIDE Zone A (expect LEFT) ---
+ doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
+ JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk());
+
+ await().alias("outside zone A (LEFT)")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesEvent", "LEFT")
+ .containsEntry("allowedZonesStatus", "OUTSIDE");
+ });
+
+ // --- Create Allowed Zone B covering the CURRENT location ---
+ String allowedPolygonB = "[[50.475500, 30.510500], [50.475500, 30.511500], [50.476500, 30.511500], [50.476500, 30.510500]]";
+
+ Asset allowedZoneB = createAsset("Allowed Zone B", null);
+ doPost("/api/plugins/telemetry/ASSET/" + allowedZoneB.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
+ JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonB + "}")).andExpect(status().isOk());
+
+ // Add a new relation
+ EntityRelation relAllowedB = new EntityRelation();
+ relAllowedB.setFrom(device.getId());
+ relAllowedB.setTo(allowedZoneB.getId());
+ relAllowedB.setType("AllowedZone");
+ doPost("/api/relation", relAllowedB).andExpect(status().isOk());
+
+ awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(device.getId(), savedCalculatedField.getId(), minAllowedScheduledUpdateIntervalInSecForCF);
+
+ // --- Same coordinates as before, but now we expect ENTERED since a new zone is registered ---
+ doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
+ JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk());
+
+ // --- Assert dynamic refresh picks up new relation and flips event back to ENTERED on the next telemetry update ---
+ await().alias("dynamic refresh rebinds allowedZones")
+ .atMost(TIMEOUT, TimeUnit.SECONDS)
+ .pollInterval(1, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus");
+ assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
+ Map m = kv(attrs);
+ assertThat(m).containsEntry("allowedZonesEvent", "ENTERED")
+ .containsEntry("allowedZonesStatus", "INSIDE");
+ });
+ }
+
private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception {
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class);
}
@@ -621,4 +1017,12 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
return doPost("/api/asset", asset, Asset.class);
}
+ private static Map kv(ArrayNode attrs) {
+ Map m = new HashMap<>();
+ for (JsonNode n : attrs) {
+ m.put(n.get("key").asText(), n.get("value").asText());
+ }
+ return m;
+ }
+
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
index b93ff0f623..d7c9ad4590 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
@@ -68,7 +68,10 @@ import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.server.actors.DefaultTbActorSystem;
import org.thingsboard.server.actors.TbActorId;
import org.thingsboard.server.actors.TbActorMailbox;
+import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId;
import org.thingsboard.server.actors.TbEntityActorId;
+import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityActor;
+import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityMessageProcessor;
import org.thingsboard.server.actors.device.DeviceActor;
import org.thingsboard.server.actors.device.DeviceActorMessageProcessor;
import org.thingsboard.server.actors.device.SessionInfo;
@@ -99,6 +102,7 @@ import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadCo
import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
@@ -150,6 +154,8 @@ import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.queue.memory.InMemoryStorage;
import org.thingsboard.server.service.cf.CfRocksDb;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest;
import org.thingsboard.server.service.security.auth.rest.LoginRequest;
@@ -1099,6 +1105,18 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
});
}
+ protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(EntityId entityId, CalculatedFieldId cfId, int scheduledUpdateInterval) {
+ CalculatedFieldEntityMessageProcessor processor = getCalculatedFieldEntityMessageProcessor(entityId);
+ Map statesMap = (Map) ReflectionTestUtils.getField(processor, "states");
+ Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> {
+ CalculatedFieldState calculatedFieldState = statesMap.get(cfId);
+ boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastDynamicArgumentsRefreshTs()
+ < System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval);
+ log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady);
+ return isReady;
+ });
+ }
+
protected static String getMapName(FeatureType featureType) {
switch (featureType) {
case ATTRIBUTES:
@@ -1120,6 +1138,16 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return (DeviceActorMessageProcessor) ReflectionTestUtils.getField(actor, "processor");
}
+ protected CalculatedFieldEntityMessageProcessor getCalculatedFieldEntityMessageProcessor(EntityId entityId) {
+ DefaultTbActorSystem actorSystem = (DefaultTbActorSystem) ReflectionTestUtils.getField(actorService, "system");
+ ConcurrentMap actors = (ConcurrentMap) ReflectionTestUtils.getField(actorSystem, "actors");
+ Awaitility.await("CF entity actor was created").atMost(TIMEOUT, TimeUnit.SECONDS)
+ .until(() -> actors.containsKey(new TbCalculatedFieldEntityActorId(entityId)));
+ TbActorMailbox actorMailbox = actors.get(new TbCalculatedFieldEntityActorId(entityId));
+ CalculatedFieldEntityActor actor = (CalculatedFieldEntityActor) ReflectionTestUtils.getField(actorMailbox, "actor");
+ return (CalculatedFieldEntityMessageProcessor) ReflectionTestUtils.getField(actor, "processor");
+ }
+
protected void updateDefaultTenantProfileConfig(Consumer updater) throws ThingsboardException {
updateDefaultTenantProfile(tenantProfile -> {
TenantProfileData profileData = tenantProfile.getProfileData();
diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
index ae2972b0cc..53648af441 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
@@ -104,7 +104,10 @@ public class AiModelControllerTest extends AbstractControllerTest {
var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class);
var newModelConfig = OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key-updated"))
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
+ .apiKey("test-api-key-updated")
+ .build())
.modelId("o4-mini")
.temperature(0.2)
.topP(0.4)
@@ -270,7 +273,7 @@ public class AiModelControllerTest extends AbstractControllerTest {
.tenantId(tenantId)
.name("Test model 1")
.configuration(OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key"))
+ .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build())
.modelId("o3-pro")
.build())
.build(), AiModel.class);
@@ -594,7 +597,10 @@ public class AiModelControllerTest extends AbstractControllerTest {
private AiModel constructValidOpenAiModel(String name) {
var modelConfig = OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key"))
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
+ .apiKey("test-api-key")
+ .build())
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java
index 90b5cbeef2..e160fff59e 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java
@@ -1080,6 +1080,30 @@ public class AssetControllerTest extends AbstractControllerTest {
testEntityDaoWithRelationsTransactionalException(assetDao, savedTenant.getId(), assetId, "/api/asset/" + assetId);
}
+ @Test
+ public void testSaveAssetWithUniquifyStrategy() throws Exception {
+ Asset asset = new Asset();
+ asset.setName("My unique asset");
+ asset.setType("default");
+ doPost("/api/asset", asset, Asset.class);
+
+ doPost("/api/asset", asset).andExpect(status().isBadRequest());
+
+ doPost("/api/asset?nameConflictPolicy=FAIL", asset).andExpect(status().isBadRequest());
+
+ Asset secondAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY", asset, Asset.class);
+ assertThat(secondAsset.getName()).startsWith("My unique asset_");
+
+ Asset thirdAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", asset, Asset.class);
+ assertThat(thirdAsset.getName()).startsWith("My unique asset-");
+
+ Asset fourthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class);
+ assertThat(fourthAsset.getName()).isEqualTo("My unique asset_1");
+
+ Asset fifthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class);
+ assertThat(fifthAsset.getName()).isEqualTo("My unique asset_2");
+ }
+
private Asset createAsset(String name) {
Asset asset = new Asset();
asset.setName(name);
diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
index af43b34558..27622b347a 100644
--- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
@@ -29,15 +29,23 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@DaoSqlTest
public class CalculatedFieldControllerTest extends AbstractControllerTest {
@@ -84,7 +92,35 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
- assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getCalculatedFieldConfig());
+ assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getSimpleCalculatedFieldConfig());
+ assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
+
+ savedCalculatedField.setName("Test CF");
+
+ CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
+
+ assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName());
+ assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1);
+
+ doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testSaveGeofencingCalculatedField() throws Exception {
+ Device testDevice = createDevice("Test device", "1234567890");
+ CalculatedField calculatedField = getCalculatedField(testDevice.getId(), getGeofencingCalculatedFieldConfig());
+
+ CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
+
+ assertThat(savedCalculatedField).isNotNull();
+ assertThat(savedCalculatedField.getId()).isNotNull();
+ assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0);
+ assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId());
+ assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
+ assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
+ assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
+ assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getGeofencingCalculatedFieldConfig());
assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
savedCalculatedField.setName("Test CF");
@@ -128,17 +164,41 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
}
private CalculatedField getCalculatedField(DeviceId deviceId) {
+ return getCalculatedField(deviceId, getSimpleCalculatedFieldConfig());
+ }
+
+ private CalculatedField getCalculatedField(DeviceId deviceId, CalculatedFieldConfiguration configuration) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(deviceId);
calculatedField.setType(CalculatedFieldType.SIMPLE);
calculatedField.setName("Test Calculated Field");
calculatedField.setConfigurationVersion(1);
- calculatedField.setConfiguration(getCalculatedFieldConfig());
+ calculatedField.setConfiguration(configuration);
calculatedField.setVersion(1L);
return calculatedField;
}
- private CalculatedFieldConfiguration getCalculatedFieldConfig() {
+ private CalculatedFieldConfiguration getGeofencingCalculatedFieldConfig() {
+ var config = new GeofencingCalculatedFieldConfiguration();
+
+ var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ refDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "FromSafeArea")));
+
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ zoneGroupConfiguration.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration);
+
+ Output output = new Output();
+ output.setType(OutputType.TIME_SERIES);
+
+ config.setEntityCoordinates(new EntityCoordinates("latitide", "longitude"));
+ config.setZoneGroups(Map.of("safeArea", zoneGroupConfiguration));
+ config.setScheduledUpdateEnabled(false);
+ config.setOutput(output);
+
+ return config;
+ }
+
+ private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() {
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
Argument argument = new Argument();
diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
index f312e49f71..f4e91f993e 100644
--- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
@@ -33,6 +33,7 @@ import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ContextConfiguration;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.Customer;
+import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
@@ -462,6 +463,27 @@ public class CustomerControllerTest extends AbstractControllerTest {
testEntityDaoWithRelationsTransactionalException(customerDao, savedTenant.getId(), customerId, "/api/customer/" + customerId);
}
+ @Test
+ public void testSaveCustomerWithUniquifyStrategy() throws Exception {
+ Customer customer = new Customer();
+ customer.setTitle("My unique customer");
+ Customer savedCustomer = doPost("/api/customer", customer, Customer.class);
+
+ doPost("/api/customer?nameConflictPolicy=FAIL", customer).andExpect(status().isBadRequest());
+
+ Customer secondCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY", customer, Customer.class);
+ assertThat(secondCustomer.getName()).startsWith("My unique customer_");
+
+ Customer thirdCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", customer, Customer.class);
+ assertThat(thirdCustomer.getName()).startsWith("My unique customer-");
+
+ Customer fourthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class);
+ assertThat(fourthCustomer.getName()).isEqualTo("My unique customer_1");
+
+ Customer fifthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class);
+ assertThat(fifthCustomer.getName()).isEqualTo("My unique customer_2");
+ }
+
private Customer createCustomer(String title) {
Customer customer = new Customer();
customer.setTitle(title);
diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
index 36cede9e84..ca5f146a88 100644
--- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
@@ -1608,6 +1608,30 @@ public class DeviceControllerTest extends AbstractControllerTest {
assertThat(device.getVersion()).isEqualTo(3);
}
+ @Test
+ public void testSaveDeviceWithUniquifyStrategy() throws Exception {
+ Device device = new Device();
+ device.setName("My unique device");
+ device.setType("default");
+ Device savedDevice = doPost("/api/device", device, Device.class);
+
+ doPost("/api/device", device).andExpect(status().isBadRequest());
+
+ doPost("/api/device?nameConflictPolicy=FAIL", device).andExpect(status().isBadRequest());
+
+ Device secondDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY", device, Device.class);
+ assertThat(secondDevice.getName()).startsWith("My unique device_");
+
+ Device thirdDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", device, Device.class);
+ assertThat(thirdDevice.getName()).startsWith("My unique device-");
+
+ Device fourthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class);
+ assertThat(fourthDevice.getName()).isEqualTo("My unique device_1");
+
+ Device fifthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class);
+ assertThat(fifthDevice.getName()).isEqualTo("My unique device_2");
+ }
+
private Device createDevice(String name) {
Device device = new Device();
device.setName(name);
diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
index 1f80c1f379..775df230b6 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
@@ -77,6 +77,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@@ -431,14 +432,17 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
List alarmFields = new ArrayList<>();
alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type"));
+ alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "originatorDisplayName"));
EntityTypeFilter assetTypeFilter = new EntityTypeFilter();
assetTypeFilter.setEntityType(EntityType.ASSET);
AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields);
PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10);
- List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList();
+ List retrievedAlarmTypes = alarmPageData.getData().stream().map(AlarmData::getType).toList();
assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes);
+ List retrievedAlarmDisplayName = alarmPageData.getData().stream().map(AlarmData::getOriginatorDisplayName).toList();
+ assertThat(retrievedAlarmDisplayName).containsExactlyInAnyOrderElementsOf(assets.stream().map(Asset::getLabel).toList());
KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1");
List keyFilters = Collections.singletonList(nameFilter);
@@ -519,6 +523,67 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
Assert.assertEquals(1, filteredAssetAlamData.getTotalElements());
}
+ @Test
+ public void testFindAlarmsWithEntityFilterAndLatestValues() throws Exception {
+ loginTenantAdmin();
+ List devices = new ArrayList<>();
+ List temps = new ArrayList<>();
+ List deviceNames = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ Device device = new Device();
+ device.setCustomerId(customerId);
+ device.setName("Device" + i);
+ device.setType("default");
+ device.setLabel("testLabel" + (int) (Math.random() * 1000));
+ device = doPost("/api/device", device, Device.class);
+ devices.add(device);
+ deviceNames.add(device.getName());
+
+ int temp = i * 10;
+ temps.add(String.valueOf(temp));
+ JsonNode content = JacksonUtil.toJsonNode("{\"temperature\": " + temp + "}");
+ doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", content)
+ .andExpect(status().isOk());
+ Thread.sleep(1);
+ }
+
+ for (int i = 0; i < devices.size(); i++) {
+ Alarm alarm = new Alarm();
+ alarm.setCustomerId(customerId);
+ alarm.setOriginator(devices.get(i).getId());
+ String type = "device alarm" + i;
+ alarm.setType(type);
+ alarm.setSeverity(AlarmSeverity.WARNING);
+ doPost("/api/alarm", alarm, Alarm.class);
+ Thread.sleep(1);
+ }
+
+ AlarmDataPageLink pageLink = new AlarmDataPageLink();
+ pageLink.setPage(0);
+ pageLink.setPageSize(100);
+ pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "created_time")));
+
+ List alarmFields = new ArrayList<>();
+ alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type"));
+
+ List entityFields = new ArrayList<>();
+ entityFields.add(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
+
+ List latestValues = new ArrayList<>();
+ latestValues.add(new EntityKey(EntityKeyType.TIME_SERIES, "temperature"));
+
+ EntityTypeFilter deviceTypeFilter = new EntityTypeFilter();
+ deviceTypeFilter.setEntityType(EntityType.DEVICE);
+ AlarmDataQuery deviceAlarmQuery = new AlarmDataQuery(deviceTypeFilter, pageLink, entityFields, latestValues, null, alarmFields);
+
+ PageData alarmPageData = findAlarmsByQueryAndCheck(deviceAlarmQuery, 10);
+ List retrievedAlarmTemps = alarmPageData.getData().stream().map(alarmData -> alarmData.getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).toList();
+ assertThat(retrievedAlarmTemps).containsExactlyInAnyOrderElementsOf(temps);
+
+ List retrievedDeviceNames = alarmPageData.getData().stream().map(alarmData -> alarmData.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()).toList();
+ assertThat(retrievedDeviceNames).containsExactlyInAnyOrderElementsOf(deviceNames);
+ }
+
private void testCountAlarmsByQuery(List alarms) throws Exception {
AlarmCountQuery countQuery = new AlarmCountQuery();
@@ -1007,6 +1072,119 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
countByQueryAndCheck(customerEntitiesQuery, 0);
}
+ @Test
+ public void testFindDevicesByDisplayName() throws Exception {
+ loginTenantAdmin();
+ int numOfDevices = 3;
+
+ for (int i = 0; i < numOfDevices; i++) {
+ Device device = new Device();
+ String name = "Device" + i;
+ device.setName(name);
+ device.setLabel("Device Label " + i);
+ device.setType("testFindDevicesByDisplayName");
+
+ Device savedDevice = doPost("/api/device?accessToken=" + name, device, Device.class);
+ }
+
+ DeviceTypeFilter filter = new DeviceTypeFilter();
+ filter.setDeviceTypes(List.of("testFindDevicesByDisplayName"));
+ filter.setDeviceNameFilter("");
+
+ KeyFilter displayNameFilter = getEntityFieldEqualFilter("displayName", "Device Label " + 0);
+
+ EntityDataSortOrder sortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC
+ );
+ EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder);
+ List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"));
+
+ // all devices with ownerName = TEST TENANT
+ EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), Collections.emptyList());
+ checkEntitiesByQuery(query, numOfDevices, (i, entity) -> {
+ String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue();
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("Device" + i, name);
+ Assert.assertEquals("Device Label " + i, displayName);
+ });
+
+ // all devices with ownerName = TEST TENANT
+ EntityDataQuery displayNameFilterQuery = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(displayNameFilter));
+ checkEntitiesByQuery(displayNameFilterQuery, 1, (i, entity) -> {
+ String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue();
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("Device" + i, name);
+ Assert.assertEquals("Device Label " + i, displayName);
+ });
+ }
+
+ @Test
+ public void testFindUsersByDisplayName() throws Exception {
+ loginTenantAdmin();
+
+ User userA = new User();
+ userA.setAuthority(Authority.TENANT_ADMIN);
+ userA.setFirstName("John");
+ userA.setLastName("Doe");
+ userA.setEmail("john.doe@tb.org");
+ userA = doPost("/api/user", userA, User.class);
+ var aId = userA.getId();
+
+ User userB = new User();
+ userB.setAuthority(Authority.TENANT_ADMIN);
+ userB.setFirstName("John");
+ userB.setEmail("john@tb.org");
+ userB = doPost("/api/user", userB, User.class);
+ var bId = userB.getId();
+
+ User userC = new User();
+ userC.setAuthority(Authority.TENANT_ADMIN);
+ userC.setLastName("Doe");
+ userC.setEmail("doe@tb.org");
+ userC = doPost("/api/user", userC, User.class);
+ var cId = userC.getId();
+
+ User userD = new User();
+ userD.setAuthority(Authority.TENANT_ADMIN);
+ userD.setEmail("noname@tb.org");
+ userD = doPost("/api/user", userD, User.class);
+ var dId = userD.getId();
+
+ EntityTypeFilter filter = new EntityTypeFilter();
+ filter.setEntityType(EntityType.USER);
+
+ EntityDataSortOrder sortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC
+ );
+ EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder);
+ List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"));
+
+ EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John Doe")));
+ checkEntitiesByQuery(query, 1, (i, entity) -> {
+ Assert.assertEquals(aId, entity.getEntityId());
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("John Doe", displayName);
+ });
+ query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John")));
+ checkEntitiesByQuery(query, 1, (i, entity) -> {
+ Assert.assertEquals(bId, entity.getEntityId());
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("John", displayName);
+ });
+ query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "Doe")));
+ checkEntitiesByQuery(query, 1, (i, entity) -> {
+ Assert.assertEquals(cId, entity.getEntityId());
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("Doe", displayName);
+ });
+ query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "noname@tb.org")));
+ checkEntitiesByQuery(query, 1, (i, entity) -> {
+ Assert.assertEquals(dId, entity.getEntityId());
+ String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue();
+ Assert.assertEquals("noname@tb.org", displayName);
+ });
+ }
+
@Test
public void testFindDevicesByOwnerNameAndOwnerType() throws Exception {
loginTenantAdmin();
@@ -1044,19 +1222,30 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
// all devices with ownerName = TEST TENANT
EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerNameFilter));
- checkEntitiesByQuery(query, numOfDevices, TEST_TENANT_NAME, "TENANT");
+ BiConsumer checkFunction = (i, entity) -> {
+ String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue();
+ String ownerName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerName", new TsValue(0, "Invalid")).getValue();
+ String ownerType = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerType", new TsValue(0, "Invalid")).getValue();
+ String alarmActiveTime = entity.getLatest().get(EntityKeyType.ATTRIBUTE).getOrDefault("alarmActiveTime", new TsValue(0, "-1")).getValue();
+
+ Assert.assertEquals("Device" + i, name);
+ Assert.assertEquals(TEST_TENANT_NAME, ownerName);
+ Assert.assertEquals("TENANT", ownerType);
+ Assert.assertEquals("1" + i, alarmActiveTime);
+ };
+ checkEntitiesByQuery(query, numOfDevices, checkFunction);
// all devices with wrong ownerName
EntityDataQuery wrongTenantNameQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, wrongOwnerNameFilter));
- checkEntitiesByQuery(wrongTenantNameQuery, 0, null, null);
+ checkEntitiesByQuery(wrongTenantNameQuery, 0, null);
// all devices with owner type = TENANT
EntityDataQuery tenantEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerTypeFilter));
- checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, TEST_TENANT_NAME, "TENANT");
+ checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, checkFunction);
// all devices with owner type = CUSTOMER
EntityDataQuery customerEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, customerOwnerTypeFilter));
- checkEntitiesByQuery(customerEntitiesQuery, 0, null, null);
+ checkEntitiesByQuery(customerEntitiesQuery, 0, null);
}
@Test
@@ -1102,6 +1291,28 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
findByQueryAndCheck(query, 0);
}
+ private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, BiConsumer checkFunction) throws Exception {
+ await()
+ .alias("data by query")
+ .atMost(30, TimeUnit.SECONDS)
+ .until(() -> {
+ var data = findByQuery(query);
+ var loadedEntities = new ArrayList<>(data.getData());
+ return loadedEntities.size() == expectedNumOfDevices;
+ });
+ if (expectedNumOfDevices == 0) {
+ return;
+ }
+ var data = findByQuery(query);
+ var loadedEntities = new ArrayList<>(data.getData());
+
+ Assert.assertEquals(expectedNumOfDevices, loadedEntities.size());
+
+ for (int i = 0; i < expectedNumOfDevices; i++) {
+ checkFunction.accept(i, loadedEntities.get(i));
+ }
+ }
+
private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, String expectedOwnerName, String expectedOwnerType) throws Exception {
await()
.alias("data by query")
diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java
index 10b49dfe1d..167048a969 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java
@@ -853,4 +853,29 @@ public class EntityViewControllerTest extends AbstractControllerTest {
EntityViewId entityViewId = getNewSavedEntityView("EntityView for Test WithRelations Transactional Exception").getId();
testEntityDaoWithRelationsTransactionalException(entityViewDao, tenantId, entityViewId, "/api/entityView/" + entityViewId);
}
+
+ @Test
+ public void testSaveEntityViewWithUniquifyStrategy() throws Exception {
+ EntityView view = new EntityView();
+ view.setEntityId(testDevice.getId());
+ view.setTenantId(tenantId);
+ view.setType("default");
+ view.setName("My unique view");
+
+ EntityView savedView = doPost("/api/entityView", view, EntityView.class);
+
+ doPost("/api/entityView?nameConflictPolicy=FAIL", view).andExpect(status().isBadRequest());
+
+ EntityView secondView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY", view, EntityView.class);
+ assertThat(secondView.getName()).startsWith("My unique view_");
+
+ EntityView thirdView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", view, EntityView.class);
+ assertThat(thirdView.getName()).startsWith("My unique view-");
+
+ EntityView fourthView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class);
+ assertThat(fourthView.getName()).isEqualTo("My unique view_1");
+
+ EntityView fifthEntityView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class);
+ assertThat(fifthEntityView.getName()).isEqualTo("My unique view_2");
+ }
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java
index 3b194de7b4..352e1fcfeb 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java
@@ -31,6 +31,7 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.DashboardInfo;
import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.StringUtils;
@@ -321,18 +322,15 @@ public class TbResourceControllerTest extends AbstractControllerTest {
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
Assert.assertNotNull(referenceValues);
- var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
+ var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
});
Assert.assertNotNull(widgetTypeInfos);
Assert.assertFalse(widgetTypeInfos.isEmpty());
Assert.assertEquals(1, widgetTypeInfos.size());
- var dashboardInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0);
- Assert.assertNotNull(dashboardInfo);
-
- WidgetTypeInfo foundedWidgetType = doGet("/api/widgetTypeInfo/" + savedWidgetType.getId().getId().toString(), WidgetTypeInfo.class);
- Assert.assertNotNull(foundedWidgetType);
- Assert.assertEquals(foundedWidgetType, dashboardInfo);
+ var widgetTypeInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0);
+ Assert.assertNotNull(widgetTypeInfo);
+ Assert.assertEquals(new EntityInfo(savedWidgetType.getId(), savedWidgetType.getName()), widgetTypeInfo);
}
@Test
@@ -372,7 +370,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
Assert.assertTrue(isSuccess);
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
- var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
+ var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
});
Assert.assertNull(widgetTypeInfos);
}
@@ -417,7 +415,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
Assert.assertNotNull(referenceValues);
- var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
+ var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
});
Assert.assertNotNull(dashboardInfos);
Assert.assertFalse(dashboardInfos.isEmpty());
@@ -425,10 +423,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
var dashboardInfo = dashboardInfos.get(EntityType.DASHBOARD.name()).get(0);
Assert.assertNotNull(dashboardInfo);
-
- DashboardInfo foundDashboard = doGet("/api/dashboard/info/" + savedDashboard.getId().getId().toString(), DashboardInfo.class);
- Assert.assertNotNull(foundDashboard);
- Assert.assertEquals(foundDashboard, dashboardInfo);
+ Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo);
}
@Test
@@ -469,7 +464,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
Assert.assertTrue(isSuccess);
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
- var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
+ var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() {
});
Assert.assertNull(dashboardInfos);
}
diff --git a/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java
new file mode 100644
index 0000000000..30c8448f5b
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java
@@ -0,0 +1,196 @@
+/**
+ * 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.edge;
+
+import com.datastax.oss.driver.api.core.uuid.Uuids;
+import com.google.protobuf.AbstractMessage;
+import com.google.protobuf.InvalidProtocolBufferException;
+import org.junit.Assert;
+import org.junit.Test;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.ai.AiModel;
+import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
+import org.thingsboard.server.gen.edge.v1.UpdateMsgType;
+import org.thingsboard.server.gen.edge.v1.UplinkMsg;
+import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@DaoSqlTest
+public class AiModelEdgeTest extends AbstractEdgeTest {
+
+ private static final String DEFAULT_AI_MODEL_NAME = "Edge Test AiModel";
+ private static final String UPDATED_AI_MODEL_NAME = "Updated Edge Test AiModel";
+
+ @Test
+ public void testAiModel_create_update_delete() throws Exception {
+ // create AiModel
+ AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME);
+
+ edgeImitator.expectMessageAmount(1);
+ AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class);
+ Assert.assertTrue(edgeImitator.waitForMessages());
+
+ AbstractMessage latestMessage = edgeImitator.getLatestMessage();
+ Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg);
+ AiModelUpdateMsg aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage;
+ Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType());
+ Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB());
+ Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB());
+ AiModel aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true);
+ Assert.assertNotNull(aiModelFromMsg);
+
+ Assert.assertEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName());
+ Assert.assertEquals(savedAiModel.getTenantId(), aiModelFromMsg.getTenantId());
+
+ // update AiModel
+ edgeImitator.expectMessageAmount(1);
+ savedAiModel.setName(UPDATED_AI_MODEL_NAME);
+ savedAiModel = doPost("/api/ai/model", savedAiModel, AiModel.class);
+ Assert.assertTrue(edgeImitator.waitForMessages());
+
+ latestMessage = edgeImitator.getLatestMessage();
+ Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg);
+ aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage;
+ aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true);
+ Assert.assertNotNull(aiModelFromMsg);
+ Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType());
+ Assert.assertEquals(UPDATED_AI_MODEL_NAME, aiModelFromMsg.getName());
+
+ // delete AiModel
+ edgeImitator.expectMessageAmount(1);
+ doDelete("/api/ai/model/" + savedAiModel.getUuidId())
+ .andExpect(status().isOk());
+ Assert.assertTrue(edgeImitator.waitForMessages());
+
+ latestMessage = edgeImitator.getLatestMessage();
+ Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg);
+ aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage;
+ Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType());
+ Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB());
+ Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB());
+ }
+
+ @Test
+ public void testSendAiModelToCloud() throws Exception {
+ AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME);
+ UUID uuid = Uuids.timeBased();
+ UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE);
+
+ checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName());
+ }
+
+ @Test
+ public void testUpdateAiModelNameOnCloud() throws Exception {
+ AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME);
+ UUID uuid = Uuids.timeBased();
+ UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE);
+
+ checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName());
+
+ aiModel.setName(UPDATED_AI_MODEL_NAME);
+ UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE);
+
+ checkAiModelOnCloud(updatedUplinkMsg, uuid, aiModel.getName());
+ }
+
+ @Test
+ public void testAiModelToCloudWithNameThatAlreadyExistsOnCloud() throws Exception {
+ AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME);
+
+ edgeImitator.expectMessageAmount(1);
+ AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class);
+ Assert.assertTrue(edgeImitator.waitForMessages());
+
+ UUID uuid = Uuids.timeBased();
+
+ UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE);
+
+ edgeImitator.expectResponsesAmount(1);
+ edgeImitator.expectMessageAmount(1);
+ edgeImitator.sendUplinkMsg(uplinkMsg);
+
+ Assert.assertTrue(edgeImitator.waitForResponses());
+ Assert.assertTrue(edgeImitator.waitForMessages());
+
+ Optional aiModelUpdateMsgOpt = edgeImitator.findMessageByType(AiModelUpdateMsg.class);
+ Assert.assertTrue(aiModelUpdateMsgOpt.isPresent());
+ AiModelUpdateMsg latestAiModelUpdateMsg = aiModelUpdateMsgOpt.get();
+ AiModel aiModelFromMsg = JacksonUtil.fromString(latestAiModelUpdateMsg.getEntity(), AiModel.class, true);
+ Assert.assertNotNull(aiModelFromMsg);
+ Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName());
+
+ Assert.assertNotEquals(savedAiModel.getUuidId(), uuid);
+
+ AiModel aiModelFromCloud = doGet("/api/ai/model/" + uuid, AiModel.class);
+ Assert.assertNotNull(aiModelFromCloud);
+ Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromCloud.getName());
+ }
+
+ private AiModel createSimpleAiModel(String name) {
+ AiModel aiModel = new AiModel();
+ aiModel.setTenantId(tenantId);
+ aiModel.setName(name);
+ aiModel.setConfiguration(OpenAiChatModelConfig.builder()
+ .providerConfig(new OpenAiProviderConfig(null, "test-api-key"))
+ .modelId("gpt-4o")
+ .temperature(0.5)
+ .topP(0.3)
+ .frequencyPenalty(0.1)
+ .presencePenalty(0.2)
+ .maxOutputTokens(1000)
+ .timeoutSeconds(60)
+ .maxRetries(2)
+ .build());
+ return aiModel;
+ }
+
+ private UplinkMsg getUplinkMsg(UUID uuid, AiModel aiModel, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException {
+ UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder();
+ AiModelUpdateMsg.Builder aiModelUpdateMsgBuilder = AiModelUpdateMsg.newBuilder();
+ aiModelUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits());
+ aiModelUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits());
+ aiModelUpdateMsgBuilder.setEntity(JacksonUtil.toString(aiModel));
+ aiModelUpdateMsgBuilder.setMsgType(updateMsgType);
+ testAutoGeneratedCodeByProtobuf(aiModelUpdateMsgBuilder);
+ uplinkMsgBuilder.addAiModelUpdateMsg(aiModelUpdateMsgBuilder.build());
+
+ testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder);
+
+ return uplinkMsgBuilder.build();
+ }
+
+ private void checkAiModelOnCloud(UplinkMsg uplinkMsg, UUID uuid, String resourceTitle) throws Exception {
+ edgeImitator.expectResponsesAmount(1);
+ edgeImitator.sendUplinkMsg(uplinkMsg);
+
+ Assert.assertTrue(edgeImitator.waitForResponses());
+
+ UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg();
+ Assert.assertTrue(latestResponseMsg.getSuccess());
+
+ AiModel aiModel = doGet("/api/ai/model/" + uuid, AiModel.class);
+ Assert.assertNotNull(aiModel);
+ Assert.assertEquals(resourceTitle, aiModel.getName());
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java
index 16d10d9b6e..4594435626 100644
--- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java
+++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java
@@ -29,6 +29,7 @@ import org.thingsboard.edge.rpc.EdgeGrpcClient;
import org.thingsboard.edge.rpc.EdgeRpcClient;
import org.thingsboard.server.controller.AbstractWebTest;
import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg;
+import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
@@ -358,6 +359,11 @@ public class EdgeImitator {
result.add(saveDownlinkMsg(calculatedFieldUpdateMsg));
}
}
+ if (downlinkMsg.getAiModelUpdateMsgCount() > 0) {
+ for (AiModelUpdateMsg aiModelUpdateMsg : downlinkMsg.getAiModelUpdateMsgList()) {
+ result.add(saveDownlinkMsg(aiModelUpdateMsg));
+ }
+ }
if (downlinkMsg.hasEdgeConfiguration()) {
result.add(saveDownlinkMsg(downlinkMsg.getEdgeConfiguration()));
}
diff --git a/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java
index 20f8aaf881..969f6de9f5 100644
--- a/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java
@@ -20,9 +20,12 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.server.common.data.ApiUsageRecordKey;
+import org.thingsboard.server.common.data.ApiUsageState;
import org.thingsboard.server.common.data.ApiUsageStateValue;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile;
+import org.thingsboard.server.common.data.id.ApiUsageStateId;
+import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
@@ -33,8 +36,17 @@ import org.thingsboard.server.dao.usagerecord.ApiUsageStateService;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import java.lang.reflect.Field;
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import static java.time.ZoneOffset.UTC;
+import static java.time.temporal.ChronoField.DAY_OF_MONTH;
+import static java.time.temporal.ChronoUnit.MONTHS;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
@DaoSqlTest
@@ -48,6 +60,7 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest {
private TenantId tenantId;
private Tenant savedTenant;
+ private TenantProfile savedTenantProfile;
private static final int MAX_ENABLE_VALUE = 5000;
private static final long VALUE_WARNING = 4500L;
@@ -59,7 +72,7 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest {
loginSysAdmin();
TenantProfile tenantProfile = createTenantProfile();
- TenantProfile savedTenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class);
+ savedTenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class);
Assert.assertNotNull(savedTenantProfile);
Tenant tenant = new Tenant();
@@ -109,6 +122,41 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest {
assertEquals(ApiUsageStateValue.DISABLED, apiUsageStateService.findTenantApiUsageState(tenantId).getDbStorageState());
}
+ @Test
+ public void checkStartOfNextCycle_setsNextCycleToNextMonth() throws Exception {
+ ApiUsageState apiUsageState = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID()));
+ apiUsageState.setDbStorageState(ApiUsageStateValue.ENABLED);
+ apiUsageState.setAlarmExecState(ApiUsageStateValue.ENABLED);
+ apiUsageState.setSmsExecState(ApiUsageStateValue.ENABLED);
+ apiUsageState.setTbelExecState(ApiUsageStateValue.ENABLED);
+ apiUsageState.setReExecState(ApiUsageStateValue.ENABLED);
+ apiUsageState.setTransportState(ApiUsageStateValue.ENABLED);
+ apiUsageState.setEmailExecState(ApiUsageStateValue.ENABLED);
+ apiUsageState.setJsExecState(ApiUsageStateValue.ENABLED);
+ apiUsageState.setTenantId(tenantId);
+ apiUsageState.setEntityId(tenantId);
+
+ long now = System.currentTimeMillis();
+ long currentCycleTs = now - TimeUnit.DAYS.toMillis(30);
+ long nextCycleTs = now - TimeUnit.MINUTES.toMillis(5); // < 1h ago
+ TenantApiUsageState tenantApiUsageState = new TenantApiUsageState(savedTenantProfile, apiUsageState);
+ tenantApiUsageState.setCycles(currentCycleTs, nextCycleTs);
+ Map map = new HashMap<>();
+ map.put(tenantId, tenantApiUsageState);
+
+ Field fieldToSet = DefaultTbApiUsageStateService.class.getDeclaredField("myUsageStates");
+ fieldToSet.setAccessible(true);
+ fieldToSet.set(service, map);
+
+ service.checkStartOfNextCycle();
+
+ long firstOfNextMonth = LocalDate.now()
+ .with((temporal) -> temporal.with(DAY_OF_MONTH, 1)
+ .plus(1, MONTHS))
+ .atStartOfDay(UTC).toInstant().toEpochMilli();
+ assertThat(tenantApiUsageState.getNextCycleTs()).isEqualTo(firstOfNextMonth);
+ }
+
private TenantProfile createTenantProfile() {
TenantProfile tenantProfile = new TenantProfile();
tenantProfile.setName("Tenant Profile");
diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java
new file mode 100644
index 0000000000..1f1ee32df2
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java
@@ -0,0 +1,478 @@
+/**
+ * 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.cf.ctx.state;
+
+import com.google.common.util.concurrent.Futures;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.Output;
+import org.thingsboard.server.common.data.cf.configuration.OutputType;
+import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
+import org.thingsboard.server.common.data.kv.DoubleDataEntry;
+import org.thingsboard.server.common.data.kv.JsonDataEntry;
+import org.thingsboard.server.common.data.relation.EntityRelation;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+import org.thingsboard.server.common.data.relation.RelationPathLevel;
+import org.thingsboard.server.dao.relation.RelationService;
+import org.thingsboard.server.dao.usagerecord.ApiLimitService;
+import org.thingsboard.server.service.cf.CalculatedFieldResult;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
+
+@ExtendWith(MockitoExtension.class)
+public class GeofencingCalculatedFieldStateTest {
+
+ private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("8f83eeca-b5cd-4955-9241-09d1393768c6"));
+ private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("688b529d-cfbe-4430-91c5-60b4f4e5d3cf"));
+ private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714"));
+ private final AssetId ZONE_2_ID = new AssetId(UUID.fromString("e7da6200-2096-4038-a343-ade9ea4fa3e4"));
+
+ private final SingleValueArgumentEntry latitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new DoubleDataEntry("latitude", 50.4730), 145L);
+ private final SingleValueArgumentEntry longitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 6, new DoubleDataEntry("longitude", 30.5050), 165L);
+
+ private final JsonDataEntry allowedZoneDataEntry = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]");
+ private final BaseAttributeKvEntry allowedZoneAttributeKvEntry = new BaseAttributeKvEntry(allowedZoneDataEntry, System.currentTimeMillis(), 0L);
+ private final GeofencingArgumentEntry geofencingAllowedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry));
+
+ private final JsonDataEntry restrictedZoneDataEntry = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]");
+ private final BaseAttributeKvEntry restrictedZoneAttributeKvEntry = new BaseAttributeKvEntry(restrictedZoneDataEntry, System.currentTimeMillis(), 0L);
+ private final GeofencingArgumentEntry geofencingRestrictedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_2_ID, restrictedZoneAttributeKvEntry));
+
+
+ private GeofencingCalculatedFieldState state;
+ private CalculatedFieldCtx ctx;
+
+ @Mock
+ private ApiLimitService apiLimitService;
+ @Mock
+ private RelationService relationService;
+
+ @BeforeEach
+ void setUp() {
+ when(apiLimitService.getLimit(any(), any())).thenReturn(1000L);
+ ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, relationService);
+ ctx.init();
+ state = new GeofencingCalculatedFieldState(ctx.getArgNames());
+ }
+
+ @Test
+ void testType() {
+ assertThat(state.getType()).isEqualTo(CalculatedFieldType.GEOFENCING);
+ }
+
+ @Test
+ void testUpdateState() {
+ state.arguments = new HashMap<>(Map.of(
+ ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
+ ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry
+ ));
+
+ Map newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry);
+ boolean stateUpdated = state.updateState(ctx, newArgs);
+
+ assertThat(stateUpdated).isTrue();
+ assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf(
+ Map.of(
+ ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
+ ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
+ "allowedZones", geofencingAllowedZoneArgEntry
+ )
+ );
+ }
+
+ @Test
+ void testUpdateStateWithInvalidArgumentTypeForLatitudeArgument() {
+ assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Unsupported argument entry type for latitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed.");
+ }
+
+ @Test
+ void testUpdateStateWithInvalidArgumentTypeForLongitudeArgument() {
+ assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Unsupported argument entry type for longitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed.");
+ }
+
+ @Test
+ void testUpdateStateWithInvalidArgumentTypeForGeofencingArgument() {
+ assertThatThrownBy(() -> state.updateState(ctx, Map.of("someArgumentName", latitudeArgEntry)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Unsupported argument entry type for someArgumentName argument: SINGLE_VALUE. Only GEOFENCING type is allowed.");
+ }
+
+ @Test
+ void testUpdateStateWhenUpdateExistingSingleValueArgumentEntry() {
+ state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry));
+
+ SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 190L);
+ Map newArgs = Map.of("latitude", newArgEntry);
+ boolean stateUpdated = state.updateState(ctx, newArgs);
+
+ assertThat(stateUpdated).isTrue();
+ assertThat(state.getArguments()).isEqualTo(newArgs);
+ }
+
+ @Test
+ void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithTheSameValue() {
+ state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry));
+
+ Map newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry);
+
+ boolean stateUpdated = state.updateState(ctx, newArgs);
+
+ assertThat(stateUpdated).isFalse();
+ assertThat(state.getArguments()).isEqualTo(newArgs);
+ }
+
+ @Test
+ void testUpdateStateWhenUpdateExistingSingleValueArgumentEntryWithValueOfAnotherType() {
+ state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry));
+
+ assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Unsupported argument entry type for single value argument entry: GEOFENCING");
+ }
+
+
+ @Test
+ void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithValueOfAnotherType() {
+ state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry));
+
+ assertThatThrownBy(() -> state.updateState(ctx, Map.of("allowedZones", latitudeArgEntry)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE");
+ }
+
+ @Test
+ void testIsReadyWhenNotAllArgPresent() {
+ assertThat(state.isReady()).isFalse();
+ }
+
+ @Test
+ void testIsReadyWhenAllArgPresent() {
+ state.arguments = new HashMap<>(Map.of(
+ ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
+ ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
+ "allowedZones", geofencingAllowedZoneArgEntry,
+ "restrictedZones", geofencingRestrictedZoneArgEntry
+ ));
+ assertThat(state.isReady()).isTrue();
+ }
+
+ @Test
+ void testIsReadyWhenEmptyEntryPresents() {
+ state.arguments = new HashMap<>(Map.of(
+ ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
+ ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
+ "allowedZones", geofencingAllowedZoneArgEntry,
+ "restrictedZones", geofencingRestrictedZoneArgEntry
+ ));
+
+ state.getArguments().put("noParkingZones", new GeofencingArgumentEntry());
+
+ assertThat(state.isReady()).isFalse();
+ }
+
+ @Test
+ void testPerformCalculation() throws ExecutionException, InterruptedException {
+ state.arguments = new HashMap<>(Map.of(
+ ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
+ ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
+ "allowedZones", geofencingAllowedZoneArgEntry,
+ "restrictedZones", geofencingRestrictedZoneArgEntry
+ ));
+
+ Output output = ctx.getOutput();
+ var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
+
+ when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
+ when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
+
+ CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getType()).isEqualTo(output.getType());
+ assertThat(result.getScope()).isEqualTo(output.getScope());
+ assertThat(result.getResult()).isEqualTo(
+ JacksonUtil.newObjectNode()
+ .put("allowedZonesEvent", "ENTERED")
+ .put("allowedZonesStatus", "INSIDE")
+ .put("restrictedZonesStatus", "OUTSIDE")
+ );
+
+ SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L);
+ SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L);
+
+ // move the device to new coordinates β leaves allowed, enters restricted
+ state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude));
+
+ CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get();
+
+ assertThat(result2).isNotNull();
+ assertThat(result2.getType()).isEqualTo(output.getType());
+ assertThat(result2.getScope()).isEqualTo(output.getScope());
+ assertThat(result2.getResult().get("values")).isEqualTo(
+ JacksonUtil.newObjectNode()
+ .put("allowedZonesEvent", "LEFT")
+ .put("allowedZonesStatus", "OUTSIDE")
+ .put("restrictedZonesEvent", "ENTERED")
+ .put("restrictedZonesStatus", "INSIDE")
+ );
+
+ // Check relations are created and deleted correctly for both iterations.
+ ArgumentCaptor saveCaptor = ArgumentCaptor.forClass(EntityRelation.class);
+ verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture());
+ List saveValues = saveCaptor.getAllValues();
+ assertThat(saveValues).hasSize(2);
+
+ EntityRelation relationFromFirstIteration = saveValues.get(0);
+ assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
+ assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
+ assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone");
+
+ EntityRelation relationFromSecondIteration = saveValues.get(1);
+ assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId());
+ assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID);
+ assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone");
+
+ ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
+ verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
+ EntityRelation leftRelation = deleteCaptor.getValue();
+ assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID);
+ assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId());
+ }
+
+ @Test
+ void testPerformCalculationWithOnlyTransitionEventsReportingStrategy() throws ExecutionException, InterruptedException {
+ state.arguments = new HashMap<>(Map.of(
+ ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
+ ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
+ "allowedZones", geofencingAllowedZoneArgEntry,
+ "restrictedZones", geofencingRestrictedZoneArgEntry
+ ));
+
+ Output output = ctx.getOutput();
+
+ var calculatedFieldConfig = getCalculatedFieldConfig(GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_ONLY);
+
+ ctx.setCalculatedField(getCalculatedField(calculatedFieldConfig));
+ ctx.init();
+
+ var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
+
+ when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
+ when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
+
+ CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getType()).isEqualTo(output.getType());
+ assertThat(result.getScope()).isEqualTo(output.getScope());
+ assertThat(result.getResult()).isEqualTo(
+ JacksonUtil.newObjectNode().put("allowedZonesEvent", "ENTERED")
+ );
+
+ SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L);
+ SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L);
+
+ // move the device to new coordinates β leaves allowed, enters restricted
+ state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude));
+
+ CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get();
+
+ assertThat(result2).isNotNull();
+ assertThat(result2.getType()).isEqualTo(output.getType());
+ assertThat(result2.getScope()).isEqualTo(output.getScope());
+ assertThat(result2.getResult().get("values")).isEqualTo(
+ JacksonUtil.newObjectNode()
+ .put("allowedZonesEvent", "LEFT")
+ .put("restrictedZonesEvent", "ENTERED")
+ );
+
+ // Check relations are created and deleted correctly for both iterations.
+ ArgumentCaptor saveCaptor = ArgumentCaptor.forClass(EntityRelation.class);
+ verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture());
+ List saveValues = saveCaptor.getAllValues();
+ assertThat(saveValues).hasSize(2);
+
+ EntityRelation relationFromFirstIteration = saveValues.get(0);
+ assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
+ assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
+ assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone");
+
+ EntityRelation relationFromSecondIteration = saveValues.get(1);
+ assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId());
+ assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID);
+ assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone");
+
+ ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
+ verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
+ EntityRelation leftRelation = deleteCaptor.getValue();
+ assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID);
+ assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId());
+ }
+
+ @Test
+ void testPerformCalculationWithOnlyPresenceStatusReportingStrategy() throws ExecutionException, InterruptedException {
+ state.arguments = new HashMap<>(Map.of(
+ ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
+ ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
+ "allowedZones", geofencingAllowedZoneArgEntry,
+ "restrictedZones", geofencingRestrictedZoneArgEntry
+ ));
+
+ Output output = ctx.getOutput();
+
+ var calculatedFieldConfig = getCalculatedFieldConfig(GeofencingReportStrategy.REPORT_PRESENCE_STATUS_ONLY);
+
+ ctx.setCalculatedField(getCalculatedField(calculatedFieldConfig));
+ ctx.init();
+
+ var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
+
+ when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
+ when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
+
+ CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getType()).isEqualTo(output.getType());
+ assertThat(result.getScope()).isEqualTo(output.getScope());
+ assertThat(result.getResult()).isEqualTo(
+ JacksonUtil.newObjectNode()
+ .put("allowedZonesStatus", "INSIDE")
+ .put("restrictedZonesStatus", "OUTSIDE")
+ );
+
+ SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L);
+ SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L);
+
+ // move the device to new coordinates β leaves allowed, enters restricted
+ state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude));
+
+ CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get();
+
+ assertThat(result2).isNotNull();
+ assertThat(result2.getType()).isEqualTo(output.getType());
+ assertThat(result2.getScope()).isEqualTo(output.getScope());
+ assertThat(result2.getResult().get("values")).isEqualTo(
+ JacksonUtil.newObjectNode()
+ .put("allowedZonesStatus", "OUTSIDE")
+ .put("restrictedZonesStatus", "INSIDE")
+ );
+
+ // Check relations are created and deleted correctly for both iterations.
+ ArgumentCaptor saveCaptor = ArgumentCaptor.forClass(EntityRelation.class);
+ verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture());
+ List saveValues = saveCaptor.getAllValues();
+ assertThat(saveValues).hasSize(2);
+
+ EntityRelation relationFromFirstIteration = saveValues.get(0);
+ assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
+ assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
+ assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone");
+
+ EntityRelation relationFromSecondIteration = saveValues.get(1);
+ assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId());
+ assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID);
+ assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone");
+
+ ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
+ verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
+ EntityRelation leftRelation = deleteCaptor.getValue();
+ assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID);
+ assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId());
+ }
+
+ private CalculatedField getCalculatedField() {
+ return getCalculatedField(getCalculatedFieldConfig(REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS));
+ }
+
+ private CalculatedField getCalculatedField(CalculatedFieldConfiguration configuration) {
+ CalculatedField calculatedField = new CalculatedField();
+ calculatedField.setTenantId(TENANT_ID);
+ calculatedField.setEntityId(DEVICE_ID);
+ calculatedField.setType(CalculatedFieldType.GEOFENCING);
+ calculatedField.setName("Test Geofencing Calculated Field");
+ calculatedField.setConfigurationVersion(1);
+ calculatedField.setConfiguration(configuration);
+ calculatedField.setVersion(1L);
+ return calculatedField;
+ }
+
+ private CalculatedFieldConfiguration getCalculatedFieldConfig(GeofencingReportStrategy reportStrategy) {
+ var config = new GeofencingCalculatedFieldConfiguration();
+
+ EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
+ config.setEntityCoordinates(entityCoordinates);
+
+ ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true);
+ var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "AllowedZone")));
+ allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
+ allowedZonesGroup.setRelationType("CurrentZone");
+ allowedZonesGroup.setDirection(EntitySearchDirection.TO);
+
+ ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true);
+ var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
+ restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "RestrictedZone")));
+ restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration);
+ restrictedZonesGroup.setRelationType("CurrentZone");
+ restrictedZonesGroup.setDirection(EntitySearchDirection.TO);
+
+ config.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup));
+
+ Output output = new Output();
+ output.setType(OutputType.TIME_SERIES);
+ config.setOutput(output);
+ return config;
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java
new file mode 100644
index 0000000000..6da4bdc882
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java
@@ -0,0 +1,184 @@
+/**
+ * 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.cf.ctx.state;
+
+import io.hypersistence.utils.hibernate.type.json.internal.JacksonUtil;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.thingsboard.common.util.geo.PerimeterDefinition;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
+import org.thingsboard.server.common.data.kv.JsonDataEntry;
+import org.thingsboard.server.common.data.kv.StringDataEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
+
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class GeofencingValueArgumentEntryTest {
+
+ private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714"));
+ private final AssetId ZONE_2_ID = new AssetId(UUID.fromString("e7da6200-2096-4038-a343-ade9ea4fa3e4"));
+
+ private final JsonDataEntry allowedZoneDataEntry = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]");
+ private final BaseAttributeKvEntry allowedZoneAttributeKvEntry = new BaseAttributeKvEntry(allowedZoneDataEntry, 363L, 155L);
+
+ private final JsonDataEntry restrictedZoneDataEntry = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]");
+ private final BaseAttributeKvEntry restrictedZoneAttributeKvEntry = new BaseAttributeKvEntry(restrictedZoneDataEntry, 363L, 155L);
+
+ private GeofencingArgumentEntry entry;
+
+ @BeforeEach
+ void setUp() {
+ entry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry));
+ }
+
+ @Test
+ void testArgumentEntryType() {
+ assertThat(entry.getType()).isEqualTo(ArgumentEntryType.GEOFENCING);
+ }
+
+ @Test
+ void testUpdateEntryWhenSingleEntryPassed() {
+ assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE");
+ }
+
+ @Test
+ void testUpdateEntryWhenRollingEntryPassed() {
+ assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Unsupported argument entry type for geofencing argument entry: TS_ROLLING");
+ }
+
+ @Test
+ void testUpdateEntryWithTheSameTs() {
+ BaseAttributeKvEntry differentValueSameTs = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 363L, 156L);
+ var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueSameTs, ZONE_2_ID, restrictedZoneAttributeKvEntry));
+ assertThat(entry.updateEntry(updated)).isFalse();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testUpdateEntryWhenNewVersionIsNull() {
+ BaseAttributeKvEntry differentValueNewVersionIsNull = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, null);
+ var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsNull, ZONE_2_ID, restrictedZoneAttributeKvEntry));
+
+ assertThat(entry.updateEntry(updated)).isTrue();
+ assertThat(entry.getValue()).isInstanceOf(Map.class);
+
+ Map value = (Map) entry.getValue();
+ assertThat(value).hasSize(2);
+ assertThat(value.get(ZONE_1_ID).getVersion()).isNull();
+ assertThat(value.get(ZONE_1_ID).getTs()).isEqualTo(364L);
+ assertThat(value.get(ZONE_1_ID).getPerimeterDefinition())
+ .isEqualTo(JacksonUtil.fromString(differentValueNewVersionIsNull.getJsonValue().get(), PerimeterDefinition.class));
+
+ assertThat(value.get(ZONE_2_ID).getVersion()).isEqualTo(155L);
+ assertThat(value.get(ZONE_2_ID).getTs()).isEqualTo(363L);
+ assertThat(value.get(ZONE_2_ID).getPerimeterDefinition())
+ .isEqualTo(JacksonUtil.fromString(restrictedZoneAttributeKvEntry.getJsonValue().get(), PerimeterDefinition.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testUpdateEntryWhenNewVersionIsGreaterThanCurrent() {
+ BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L);
+ var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry));
+
+ assertThat(entry.updateEntry(updated)).isTrue();
+ assertThat(entry.getValue()).isInstanceOf(Map.class);
+
+ Map value = (Map) entry.getValue();
+ assertThat(value).hasSize(2);
+ assertThat(value.get(ZONE_1_ID).getVersion()).isEqualTo(156L);
+ assertThat(value.get(ZONE_1_ID).getTs()).isEqualTo(364L);
+ assertThat(value.get(ZONE_1_ID).getPerimeterDefinition())
+ .isEqualTo(JacksonUtil.fromString(differentValueNewVersionIsSet.getJsonValue().get(), PerimeterDefinition.class));
+
+ assertThat(value.get(ZONE_2_ID).getVersion()).isEqualTo(155L);
+ assertThat(value.get(ZONE_2_ID).getTs()).isEqualTo(363L);
+ assertThat(value.get(ZONE_2_ID).getPerimeterDefinition())
+ .isEqualTo(JacksonUtil.fromString(restrictedZoneAttributeKvEntry.getJsonValue().get(), PerimeterDefinition.class));
+ }
+
+ @Test
+ void testUpdateEntryWhenNewVersionIsLessThanCurrent() {
+ BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 154L);
+ var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry));
+
+ assertThat(entry.updateEntry(updated)).isFalse();
+ }
+
+ @Test
+ void testUpdateEntryWhenNewTsAndVersionIsGreaterThenCurrentAndValueWasNotChanged() {
+ BaseAttributeKvEntry newTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 364L, 156L);
+ var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, newTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry));
+
+ assertThat(entry.updateEntry(updated)).isTrue();
+ }
+
+ @Test
+ void testUpdateEntryWithOldTs() {
+ BaseAttributeKvEntry oldTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 362L, 156L);
+ var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, oldTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry));
+
+ assertThat(entry.updateEntry(updated)).isFalse();
+ }
+
+ @Test
+ void testUpdateEntryWithNewZone() {
+ final AssetId NEW_ZONE_ID = new AssetId(UUID.fromString("a3eacf1a-6af3-4e9f-87c4-502bb25c7dc3"));
+ BaseAttributeKvEntry newZone = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L);
+ var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry, NEW_ZONE_ID, newZone));
+ assertThat(entry.updateEntry(updated)).isTrue();
+ }
+
+ @Test
+ void testIsEmpty() {
+ GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry();
+ assertThat(geofencingArgumentEntry.isEmpty()).isTrue();
+ }
+
+ @Test
+ void testIsEmptyWithEmptyMap() {
+ GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(Map.of());
+ assertThat(geofencingArgumentEntry.isEmpty()).isTrue();
+ }
+
+ @Test
+ void testInvalidKvEntryDataTypeForZoneResultInEmptyArgument() {
+ BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new StringDataEntry("zone", "someString"), 363L, 155L);
+ assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry)))
+ .isExactlyInstanceOf(IllegalArgumentException.class)
+ .hasMessage("The given string value cannot be transformed to Json object: someString");
+ }
+
+ @Test
+ void testNotParsableToPerimeterJsonKvEntryResultInExceptionTrowed() {
+ BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new JsonDataEntry("zone", "\"{}\""), 363L, 155L);
+ assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry)))
+ .isExactlyInstanceOf(IllegalArgumentException.class)
+ .hasMessage("The given string value cannot be transformed to Json object: \"{}\"");
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java
new file mode 100644
index 0000000000..f6c6778ced
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java
@@ -0,0 +1,169 @@
+/**
+ * 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.cf.ctx.state;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.thingsboard.common.util.geo.Coordinates;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
+import org.thingsboard.server.common.data.kv.JsonDataEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingEvalResult;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
+
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent.ENTERED;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent.LEFT;
+
+public class GeofencingZoneStateTest {
+
+ private final AssetId ZONE_ID = new AssetId(UUID.fromString("628730fd-d625-417f-9c6d-ae9fe4addbdb"));
+
+ private GeofencingZoneState state;
+
+ @BeforeEach
+ void setUp() {
+ String POLYGON = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
+ state = new GeofencingZoneState(ZONE_ID, new BaseAttributeKvEntry(new JsonDataEntry("zone", POLYGON), 100L, 1L));
+ }
+
+ @Test
+ void evaluate_initialInside_thenInsideAgain() {
+ var inside = new Coordinates(50.4730, 30.5050);
+ // first evaluation: no prior state -> ENTERED
+ assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE));
+ // same position again -> INSIDE (steady state)
+ assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE));
+ }
+
+ @Test
+ void evaluate_initialOutside_thenOutsideAgain() {
+ var outside = new Coordinates(50.4760, 30.5110);
+ // first evaluation: no prior state -> OUTSIDE
+ assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE));
+ // same position again -> OUTSIDE (steady state)
+ assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE));
+ }
+
+ @Test
+ void evaluate_inside_thenLeave() {
+ var inside = new Coordinates(50.4730, 30.5050);
+ var outside = new Coordinates(50.4760, 30.5110);
+ // enter
+ assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE));
+ // leave -> LEFT
+ assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(LEFT, OUTSIDE));
+ // still outside -> OUTSIDE
+ assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE));
+ }
+
+ @Test
+ void evaluate_outside_thenEnter() {
+ var outside = new Coordinates(50.4760, 30.5110);
+ var inside = new Coordinates(50.4730, 30.5050);
+ // start outside
+ assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE));
+ // cross boundary -> ENTERED
+ assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE));
+ // remain inside -> INSIDE
+ assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE));
+ }
+
+ @Test
+ void update_withNewerVersion_updatesState_andResetsPresence() {
+ // arrange: establish a prior presence to ensure itβs reset on update
+ var inside = new Coordinates(50.4730, 30.5050);
+ assertThat(state.evaluate(inside)).isNotNull(); // sets lastPresence internally
+
+ String NEW_POLYGON = "[[50.470000, 30.502000], [50.470000, 30.503000], [50.471000, 30.503000], [50.471000, 30.502000]]";
+ GeofencingZoneState newer = new GeofencingZoneState(
+ ZONE_ID,
+ new BaseAttributeKvEntry(new JsonDataEntry("zone", NEW_POLYGON), 200L, 2L)
+ );
+
+ // act
+ boolean changed = state.update(newer);
+
+ // assert
+ assertThat(changed).isTrue();
+ assertThat(state.getTs()).isEqualTo(200L);
+ assertThat(state.getVersion()).isEqualTo(2L);
+ assertThat(state.getPerimeterDefinition()).isNotNull();
+ assertThat(state.getLastPresence()).isNull(); // must be reset on successful update
+ }
+
+ @Test
+ void update_withEqualVersion_doesNothing() {
+ // arrange: same version (1L) but different ts/polygon should still be ignored
+ String SOME_POLYGON = "[[50.472500, 30.504500], [50.472500, 30.505500], [50.473500, 30.505500], [50.473500, 30.504500]]";
+ GeofencingZoneState sameVersion = new GeofencingZoneState(
+ ZONE_ID,
+ new BaseAttributeKvEntry(new JsonDataEntry("zone", SOME_POLYGON), 300L, 1L)
+ );
+
+ // act
+ boolean changed = state.update(sameVersion);
+
+ // assert: nothing changes
+ assertThat(changed).isFalse();
+ assertThat(state.getTs()).isEqualTo(100L);
+ assertThat(state.getVersion()).isEqualTo(1L);
+ }
+
+ @Test
+ void update_withNullNewVersion_alwaysApplies_andCopiesNull() {
+ // arrange: the implementation updates if newVersion == null
+ String OTHER_POLYGON = "[[50.471000, 30.506000], [50.471000, 30.507000], [50.472000, 30.507000], [50.472000, 30.506000]]";
+ GeofencingZoneState nullVersion = new GeofencingZoneState(
+ ZONE_ID,
+ new BaseAttributeKvEntry(new JsonDataEntry("zone", OTHER_POLYGON), 400L, null)
+ );
+
+ // act
+ boolean changed = state.update(nullVersion);
+
+ // assert: applied and version copied as null
+ assertThat(changed).isTrue();
+ assertThat(state.getTs()).isEqualTo(400L);
+ assertThat(state.getVersion()).isNull();
+ assertThat(state.getLastPresence()).isNull();
+ }
+
+ @Test
+ void update_withNewVersionWhenExistingIsNull_alwaysApplies_andCopiesNew() {
+ // arrange: the implementation updates if newVersion == null
+ String OTHER_POLYGON = "[[50.471000, 30.506000], [50.471000, 30.507000], [50.472000, 30.507000], [50.472000, 30.506000]]";
+ GeofencingZoneState newVersion = new GeofencingZoneState(
+ ZONE_ID,
+ new BaseAttributeKvEntry(new JsonDataEntry("zone", OTHER_POLYGON), 400L, 2L)
+ );
+ state.setVersion(null);
+
+ // act
+ boolean changed = state.update(newVersion);
+
+ // assert: applied and version copied as null
+ assertThat(changed).isTrue();
+ assertThat(state.getTs()).isEqualTo(400L);
+ assertThat(state.getVersion()).isEqualTo(2);
+ assertThat(state.getLastPresence()).isNull();
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java
index 8ed42c43e8..8c714bc0e7 100644
--- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java
@@ -44,6 +44,7 @@ import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
@@ -77,7 +78,7 @@ public class ScriptCalculatedFieldStateTest {
@BeforeEach
void setUp() {
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L);
- ctx = new CalculatedFieldCtx(getCalculatedField(), tbelInvokeService, apiLimitService);
+ ctx = new CalculatedFieldCtx(getCalculatedField(), tbelInvokeService, apiLimitService, null);
ctx.init();
state = new ScriptCalculatedFieldState(ctx.getArgNames());
}
@@ -124,7 +125,7 @@ public class ScriptCalculatedFieldStateTest {
void testPerformCalculation() throws ExecutionException, InterruptedException {
state.arguments = new HashMap<>(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry));
- CalculatedFieldResult result = state.performCalculation(ctx).get();
+ CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
Output output = getCalculatedFieldConfig().getOutput();
@@ -140,7 +141,7 @@ public class ScriptCalculatedFieldStateTest {
"assetHumidity", new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new LongDataEntry("a", 45L), 10L)
));
- CalculatedFieldResult result = state.performCalculation(ctx).get();
+ CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
Output output = getCalculatedFieldConfig().getOutput();
diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
index c1616a85db..3aef8896de 100644
--- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
@@ -71,7 +71,7 @@ public class SimpleCalculatedFieldStateTest {
@BeforeEach
void setUp() {
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L);
- ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService);
+ ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, null);
ctx.init();
state = new SimpleCalculatedFieldState(ctx.getArgNames());
}
@@ -123,7 +123,8 @@ public class SimpleCalculatedFieldStateTest {
Map newArgs = Map.of("key3", new TsRollingArgumentEntry(10, 30000L));
assertThatThrownBy(() -> state.updateState(ctx, newArgs))
.isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Rolling argument entry is not supported for simple calculated fields.");
+ .hasMessage("Unsupported argument type detected for argument: key3. " +
+ "Rolling argument entry is not supported for simple calculated fields.");
}
@Test
@@ -134,7 +135,7 @@ public class SimpleCalculatedFieldStateTest {
"key3", key3ArgEntry
));
- CalculatedFieldResult result = state.performCalculation(ctx).get();
+ CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
Output output = getCalculatedFieldConfig().getOutput();
@@ -151,7 +152,7 @@ public class SimpleCalculatedFieldStateTest {
"key3", key3ArgEntry
));
- assertThatThrownBy(() -> state.performCalculation(ctx))
+ assertThatThrownBy(() -> state.performCalculation(ctx.getEntityId(), ctx))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument 'key2' is not a number.");
}
@@ -164,7 +165,7 @@ public class SimpleCalculatedFieldStateTest {
"key3", key3ArgEntry
));
- CalculatedFieldResult result = state.performCalculation(ctx).get();
+ CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
Output output = getCalculatedFieldConfig().getOutput();
@@ -185,7 +186,7 @@ public class SimpleCalculatedFieldStateTest {
output.setDecimalsByDefault(3);
ctx.setOutput(output);
- CalculatedFieldResult result = state.performCalculation(ctx).get();
+ CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(output.getType());
diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java
index 2321446b44..b6be136f82 100644
--- a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java
@@ -253,7 +253,7 @@ class DefaultTbAiModelServiceTest {
private static AiModelConfig constructValidOpenAiModelConfig() {
return OpenAiChatModelConfig.builder()
- .providerConfig(new OpenAiProviderConfig("test-api-key"))
+ .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build())
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
diff --git a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java
new file mode 100644
index 0000000000..f12a8d3c5d
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java
@@ -0,0 +1,83 @@
+/**
+ * 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.resource;
+
+import org.junit.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.GeneralFileDescriptor;
+import org.thingsboard.server.common.data.ResourceType;
+import org.thingsboard.server.common.data.TbResource;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
+import org.thingsboard.server.common.data.TbResourceInfo;
+import org.thingsboard.server.controller.AbstractControllerTest;
+import org.thingsboard.server.dao.resource.ResourceService;
+import org.thingsboard.server.dao.resource.TbResourceDataCache;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+@DaoSqlTest
+public class DefaultResourceDataCacheTest extends AbstractControllerTest {
+
+ @MockitoSpyBean
+ private ResourceService resourceService;
+ @Autowired
+ private TbResourceService tbResourceService;
+ @MockitoSpyBean
+ private TbResourceDataCache resourceDataCache;
+
+ @Test
+ public void testGetCachedResourceData() throws Exception {
+ loginTenantAdmin();
+
+ TbResource resource = new TbResource();
+ resource.setTenantId(tenantId);
+ resource.setTitle("File for AI request");
+ resource.setResourceType(ResourceType.GENERAL);
+ resource.setFileName("myTestJson.json");
+ GeneralFileDescriptor descriptor = new GeneralFileDescriptor("application/json");
+ resource.setDescriptorValue(descriptor);
+ byte[] data = "This is a test prompt for AI request.".getBytes();
+ resource.setData(data);
+ TbResourceInfo savedResource = tbResourceService.save(resource);
+ verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId());
+
+ TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
+ assertThat(cachedData.getData()).isEqualTo(data);
+ assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor);
+ verify(resourceService).getResourceDataInfo(tenantId, savedResource.getId());
+
+ // retrieve resource data second time
+ clearInvocations(resourceService);
+ TbResourceDataInfo cachedData2 = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
+ assertThat(cachedData2.getData()).isEqualTo(data);
+ verifyNoMoreInteractions(resourceService);
+
+ // delete resource, check cache
+ TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId());
+ tbResourceService.delete(resourceById, true, null);
+ verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId());
+ TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
+ assertThat(cachedDataAfterDeletion).isEqualTo(null);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
index fe416eacd5..a146730465 100644
--- a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
@@ -17,6 +17,7 @@ package org.thingsboard.server.service.resource.sql;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@@ -24,8 +25,10 @@ import org.junit.Test;
import org.junit.jupiter.api.Assertions;
import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.rule.engine.ai.TbAiNode;
+import org.thingsboard.rule.engine.ai.TbAiNodeConfiguration;
+import org.thingsboard.rule.engine.ai.TbResponseFormat;
import org.thingsboard.server.common.data.Dashboard;
-import org.thingsboard.server.common.data.DashboardInfo;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
@@ -37,26 +40,40 @@ import org.thingsboard.server.common.data.TbResourceInfoFilter;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.ai.AiModel;
+import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
+import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.id.RuleChainId;
+import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
+import org.thingsboard.server.common.data.rule.RuleChain;
+import org.thingsboard.server.common.data.rule.RuleChainMetaData;
+import org.thingsboard.server.common.data.rule.RuleChainType;
+import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
-import org.thingsboard.server.common.data.widget.WidgetTypeInfo;
import org.thingsboard.server.controller.AbstractControllerTest;
+import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.dashboard.DashboardService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.resource.ResourceService;
+import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.service.resource.TbResourceService;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -135,6 +152,10 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
private WidgetTypeService widgetTypeService;
@Autowired
private DashboardService dashboardService;
+ @Autowired
+ private RuleChainService ruleChainService;
+ @Autowired
+ private AiModelService aiModelService;
private Tenant savedTenant;
private User tenantAdmin;
@@ -453,11 +474,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
Assert.assertFalse(result.getReferences().isEmpty());
Assert.assertEquals(1, result.getReferences().size());
- WidgetTypeInfo widgetTypeInfo = (WidgetTypeInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0);
- WidgetTypeInfo foundWidgetTypeInfo = new WidgetTypeInfo(foundWidgetType);
+ EntityInfo widgetTypeInfo = (EntityInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0);
Assert.assertNotNull(widgetTypeInfo);
- Assert.assertNotNull(foundWidgetTypeInfo);
- Assert.assertEquals(widgetTypeInfo, foundWidgetTypeInfo);
+ Assert.assertEquals(widgetTypeInfo, new EntityInfo(foundWidgetType.getId(), foundWidgetType.getName()));
TbResourceInfo foundResourceInfo = resourceService.findResourceInfoById(savedTenant.getId(), savedResource.getId());
Assert.assertNotNull(foundResource);
@@ -546,11 +565,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
Assert.assertNotNull(result.getReferences());
Assert.assertEquals(1, result.getReferences().size());
- DashboardInfo dashboardInfo = (DashboardInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0);
- DashboardInfo foundDashboardInfo = dashboardService.findDashboardInfoById(savedTenant.getId(), savedDashboard.getId());
+ EntityInfo dashboardInfo = (EntityInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0);
Assert.assertNotNull(dashboardInfo);
- Assert.assertNotNull(foundDashboardInfo);
- Assert.assertEquals(foundDashboardInfo, dashboardInfo);
+ Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo);
foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId());
Assert.assertNotNull(foundResource);
@@ -598,6 +615,94 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
Assert.assertNull(foundResource);
}
+ @Test
+ public void testShouldNotDeleteResourceIfUsedInAiNode() throws Exception {
+ TbResource resource = new TbResource();
+ resource.setResourceType(ResourceType.GENERAL);
+ resource.setTitle("My resource");
+ resource.setFileName("test.json");
+ resource.setTenantId(savedTenant.getId());
+ resource.setData(" ".getBytes());
+ TbResourceInfo savedResource = tbResourceService.save(resource);
+ RuleChainMetaData ruleChain = createRuleChainReferringResource(savedResource.getId());
+
+ TbResourceDeleteResult result = tbResourceService.delete(savedResource, false, null);
+ assertThat(result).isNotNull();
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getReferences()).isNotEmpty().hasSize(1);
+ EntityInfo entityInfo = (EntityInfo) result.getReferences().get(EntityType.RULE_CHAIN.name()).get(0);
+ assertThat(entityInfo).isEqualTo(new EntityInfo(ruleChain.getRuleChainId(), "Test"));
+
+ TbResource foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId());
+ assertThat(foundResource).isNotNull();
+
+ // force delete
+ TbResourceDeleteResult deleteResult = tbResourceService.delete(savedResource, true, null);
+ assertThat(deleteResult).isNotNull();
+ assertThat(deleteResult.isSuccess()).isTrue();
+
+ TbResource resourceAfterDeletion = resourceService.findResourceById(savedTenant.getId(), savedResource.getId());
+ assertThat(resourceAfterDeletion).isNull();
+ }
+
+ private RuleChainMetaData createRuleChainReferringResource(TbResourceId resourceId) {
+ AiModel model = constructValidOpenAiModel("Test model");
+ AiModel saved = aiModelService.save(model);
+
+ RuleChain ruleChain = new RuleChain();
+ ruleChain.setTenantId(tenantId);
+ ruleChain.setName("Test");
+ ruleChain.setType(RuleChainType.CORE);
+ ruleChain.setDebugMode(true);
+ ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b")));
+ ruleChain = ruleChainService.saveRuleChain(ruleChain);
+ RuleChainId ruleChainId = ruleChain.getId();
+
+ RuleChainMetaData metaData = new RuleChainMetaData();
+ metaData.setRuleChainId(ruleChainId);
+
+ RuleNode aiNode = new RuleNode();
+ aiNode.setName("Ai request");
+ aiNode.setType(org.thingsboard.rule.engine.ai.TbAiNode.class.getName());
+ aiNode.setConfigurationVersion(TbAiNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version());
+ aiNode.setDebugSettings(DebugSettings.all());
+ TbAiNodeConfiguration configuration = new TbAiNodeConfiguration();
+ configuration.setResourceIds(Set.of(resourceId.getId()));
+ configuration.setModelId(saved.getId());
+ configuration.setResponseFormat(new TbResponseFormat.TbJsonResponseFormat());
+ configuration.setTimeoutSeconds(1);
+ configuration.setUserPrompt("What is temp");
+ aiNode.setConfiguration(JacksonUtil.valueToTree(configuration));
+
+ metaData.setNodes(Arrays.asList(aiNode));
+ metaData.setFirstNodeIndex(0);
+ ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity());
+ return ruleChainService.loadRuleChainMetaData(tenantId, ruleChainId);
+ }
+
+ private AiModel constructValidOpenAiModel(String name) {
+ var modelConfig = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
+ .apiKey("test-api-key")
+ .build())
+ .modelId("gpt-4o")
+ .temperature(0.5)
+ .topP(0.3)
+ .frequencyPenalty(0.1)
+ .presencePenalty(0.2)
+ .maxOutputTokens(1000)
+ .timeoutSeconds(60)
+ .maxRetries(2)
+ .build();
+
+ return AiModel.builder()
+ .tenantId(tenantId)
+ .name(name)
+ .configuration(modelConfig)
+ .build();
+ }
+
@Test
public void testFindTenantResourcesByTenantId() throws Exception {
loginSysAdmin();
diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java
index 461ca5a2ec..2db78871d4 100644
--- a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java
@@ -638,7 +638,9 @@ public class VersionControlTest extends AbstractControllerTest {
assertThat(importedField.getName()).isEqualTo(deviceCalculatedField.getName());
assertThat(importedField.getType()).isEqualTo(deviceCalculatedField.getType());
assertThat(importedField.getId()).isNotEqualTo(deviceCalculatedField.getId());
- assertThat(importedField.getConfiguration().getArguments().get("T").getRefEntityId()).isEqualTo(importedAsset.getId());
+ assertThat(importedField.getConfiguration()).isInstanceOf(SimpleCalculatedFieldConfiguration.class);
+ SimpleCalculatedFieldConfiguration simpleCfg = (SimpleCalculatedFieldConfiguration) importedField.getConfiguration();
+ assertThat(simpleCfg.getArguments().get("T").getRefEntityId()).isEqualTo(importedAsset.getId());
});
List importedAssetCalculatedFields = findCalculatedFieldsByEntityId(importedAsset.getId());
@@ -647,7 +649,9 @@ public class VersionControlTest extends AbstractControllerTest {
assertThat(importedField.getName()).isEqualTo(assetCalculatedField.getName());
assertThat(importedField.getType()).isEqualTo(assetCalculatedField.getType());
assertThat(importedField.getId()).isNotEqualTo(assetCalculatedField.getId());
- assertThat(importedField.getConfiguration().getArguments().get("T").getRefEntityId()).isEqualTo(importedDevice.getId());
+ assertThat(importedField.getConfiguration()).isInstanceOf(SimpleCalculatedFieldConfiguration.class);
+ SimpleCalculatedFieldConfiguration simpleCfg = (SimpleCalculatedFieldConfiguration) importedField.getConfiguration();
+ assertThat(simpleCfg.getArguments().get("T").getRefEntityId()).isEqualTo(importedDevice.getId());
});
}
diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java
index 6543955ed5..9856aa18cb 100644
--- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java
+++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java
@@ -25,10 +25,10 @@ import org.springframework.boot.test.mock.mockito.SpyBean;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.msg.gateway.metrics.GatewayMetadata;
import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest;
import org.thingsboard.server.transport.mqtt.MqttTestConfigProperties;
import org.thingsboard.server.transport.mqtt.gateway.GatewayMetricsService;
-import org.thingsboard.server.common.msg.gateway.metrics.GatewayMetadata;
import org.thingsboard.server.transport.mqtt.gateway.metrics.GatewayMetricsState;
import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestCallback;
import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestClient;
@@ -43,6 +43,7 @@ import java.util.Random;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import static org.awaitility.Awaitility.await;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.mockito.Mockito.any;
@@ -112,6 +113,42 @@ public abstract class AbstractMqttTimeseriesIntegrationTest extends AbstractMqtt
processGatewayTelemetryTest(GATEWAY_TELEMETRY_TOPIC, expectedKeys, payload.getBytes(), deviceName1, deviceName2);
}
+ @Test
+ public void testAckIsReceivedOnFailedPublishMessage() throws Exception {
+ String devicePayload = "[{\"ts\": 10000, \"values\": " + PAYLOAD_VALUES_STR + "}]";
+ String payloadA = "{\"Device A\": " + devicePayload + "}";
+
+ String deviceBPayload = "[{\"ts\": 10000, \"values\": " + PAYLOAD_VALUES_STR + "}]";
+ String payloadB = "{\"Device B\": " + deviceBPayload + "}";
+
+ testAckIsReceivedOnFailedPublishMessage("Device A", payloadA.getBytes(), "Device B", payloadB.getBytes());
+ }
+
+ protected void testAckIsReceivedOnFailedPublishMessage(String deviceName1, byte[] payload1, String deviceName2, byte[] payload2) throws Exception {
+ updateDefaultTenantProfileConfig(profileConfiguration -> {
+ profileConfiguration.setMaxDevices(3);
+ });
+
+ MqttTestClient client = new MqttTestClient();
+ client.connectAndWait(gatewayAccessToken);
+ client.publishAndWait(GATEWAY_TELEMETRY_TOPIC, payload1);
+
+ // check device is created
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
+ assertNotNull(doGet("/api/tenant/devices?deviceName=" + deviceName1, Device.class));
+ });
+
+ client.publishAndWait(GATEWAY_TELEMETRY_TOPIC, payload2);
+ client.disconnectAndWait();
+
+ // check device was not created due to limit
+ doGet("/api/tenant/devices?deviceName=" + deviceName2).andExpect(status().isNotFound());
+
+ updateDefaultTenantProfileConfig(profileConfiguration -> {
+ profileConfiguration.setMaxDevices(0);
+ });
+ }
+
@Test
public void testGatewayConnect() throws Exception {
String payload = "{\"device\":\"Device A\"}";
diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java
index a23506a160..92b35d896c 100644
--- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java
+++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java
@@ -186,4 +186,15 @@ public abstract class AbstractMqttTimeseriesJsonIntegrationTest extends Abstract
assertFalse(callback.isPubAckReceived());
}
+ @Override
+ public void testAckIsReceivedOnFailedPublishMessage() throws Exception {
+ MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder()
+ .deviceName("Test Post Telemetry device json payload")
+ .gatewayName("Test Post Telemetry gateway json payload")
+ .transportPayloadType(TransportPayloadType.JSON)
+ .telemetryTopicFilter(POST_DATA_TELEMETRY_TOPIC)
+ .build();
+ processBeforeTest(configProperties);
+ super.testAckIsReceivedOnFailedPublishMessage();
+ }
}
diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java
index 1b6c247d96..60e5857c04 100644
--- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java
+++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java
@@ -459,6 +459,31 @@ public abstract class AbstractMqttTimeseriesProtoIntegrationTest extends Abstrac
assertFalse(callback.isPubAckReceived());
}
+ @Override
+ public void testAckIsReceivedOnFailedPublishMessage() throws Exception {
+ MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder()
+ .deviceName("Test Post Telemetry device proto payload")
+ .gatewayName("Test Post Telemetry gateway proto payload")
+ .transportPayloadType(TransportPayloadType.PROTOBUF)
+ .telemetryTopicFilter(POST_DATA_TELEMETRY_TOPIC)
+ .build();
+ processBeforeTest(configProperties);
+
+ TransportApiProtos.GatewayTelemetryMsg.Builder gatewayTelemetryMsgProtoBuilder = TransportApiProtos.GatewayTelemetryMsg.newBuilder();
+ List expectedKeys = Arrays.asList("key1", "key2", "key3", "key4", "key5");
+ String deviceName1 = "Device A";
+ String deviceName2 = "Device B";
+ TransportApiProtos.TelemetryMsg deviceATelemetryMsgProto = getDeviceTelemetryMsgProto(deviceName1, expectedKeys, 10000, 20000);
+ gatewayTelemetryMsgProtoBuilder.addAllMsg(List.of(deviceATelemetryMsgProto));
+ TransportApiProtos.GatewayTelemetryMsg payload1 = gatewayTelemetryMsgProtoBuilder.build();
+
+ TransportApiProtos.TelemetryMsg deviceBTelemetryMsgProto = getDeviceTelemetryMsgProto(deviceName2, expectedKeys, 10000, 20000);
+ TransportApiProtos.GatewayTelemetryMsg payload2 = TransportApiProtos.GatewayTelemetryMsg.newBuilder()
+ .addAllMsg(List.of(deviceBTelemetryMsgProto))
+ .build();
+ super.testAckIsReceivedOnFailedPublishMessage(deviceName1, payload1.toByteArray(), deviceName2, payload2.toByteArray());
+ }
+
private DynamicSchema getDynamicSchema() {
DeviceProfileTransportConfiguration transportConfiguration = deviceProfile.getProfileData().getTransportConfiguration();
assertTrue(transportConfiguration instanceof MqttDeviceProfileTransportConfiguration);
diff --git a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java
new file mode 100644
index 0000000000..2697b2b804
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java
@@ -0,0 +1,109 @@
+/**
+ * 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.utils;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
+import org.thingsboard.server.common.data.kv.JsonDataEntry;
+import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
+import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
+
+@ExtendWith(MockitoExtension.class)
+class CalculatedFieldUtilsTest {
+
+ private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("0a69e1e2-fcbc-4234-a4cd-3844bf54035c"));
+ private static final CalculatedFieldId CF_ID = CalculatedFieldId.fromString("ec0e91b9-6f27-4e93-946a-5fbc2707d8bc");
+ private static final DeviceId DEVICE_ID = DeviceId.fromString("1e03bd38-2010-4739-9362-160c288e36c4");
+
+ @Test
+ void toProtoAndFromProto_shouldMapGeofencingArgumentsAndZones() {
+ // given
+ CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class);
+ given(stateId.tenantId()).willReturn(TENANT_ID);
+ given(stateId.cfId()).willReturn(CF_ID);
+ given(stateId.entityId()).willReturn(DEVICE_ID);
+
+ // Build a geofencing argument with two zones (one with inside=true, one with inside=null)
+ GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry();
+ Map zoneStates = new LinkedHashMap<>();
+
+ UUID zoneId1 = UUID.fromString("624a8fff-71a2-4847-a100-ff1cf52dbe71");
+ UUID zoneId2 = UUID.fromString("e2adf6ce-9478-40b1-b0e9-4a6860cc46bb");
+
+ AssetId z1 = new AssetId(zoneId1);
+ AssetId z2 = new AssetId(zoneId2);
+
+ JsonDataEntry zone1 = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]");
+ JsonDataEntry zone2 = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]");
+
+ BaseAttributeKvEntry zone1PerimeterAttribute = new BaseAttributeKvEntry(zone1, System.currentTimeMillis(), 0L);
+ BaseAttributeKvEntry zone2PerimeterAttribute = new BaseAttributeKvEntry(zone2, System.currentTimeMillis(), 0L);
+
+ GeofencingZoneState s1 = new GeofencingZoneState(z1, zone1PerimeterAttribute);
+ s1.setLastPresence(GeofencingPresenceStatus.INSIDE);
+ GeofencingZoneState s2 = new GeofencingZoneState(z2, zone2PerimeterAttribute);
+
+ zoneStates.put(z1, s1);
+ zoneStates.put(z2, s2);
+ geofencingArgumentEntry.setZoneStates(zoneStates);
+
+ // Create cf state with the geofencing argument and add it to the state map
+ CalculatedFieldState state = new GeofencingCalculatedFieldState(List.of("geofencingArgumentTest"));
+ state.updateState(mock(CalculatedFieldCtx.class), Map.of("geofencingArgumentTest", geofencingArgumentEntry));
+
+ // when
+ CalculatedFieldStateProto proto = toProto(stateId, state);
+
+ // then
+ CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(proto);
+ assertThat(fromProto)
+ .usingRecursiveComparison()
+ .ignoringFields("requiredArguments")
+ .isEqualTo(state);
+
+ ArgumentEntry fromProtoArgument = fromProto.getArguments().get("geofencingArgumentTest");
+ assertThat(fromProtoArgument).isInstanceOf(GeofencingArgumentEntry.class);
+ GeofencingArgumentEntry fromProtoGeoArgument = (GeofencingArgumentEntry) fromProtoArgument;
+ assertThat(fromProtoGeoArgument.getZoneStates()).hasSize(2);
+ assertThat(fromProtoGeoArgument.getZoneStates().get(z1).getLastPresence()).isEqualTo(GeofencingPresenceStatus.INSIDE);
+ assertThat(fromProtoGeoArgument.getZoneStates().get(z2).getLastPresence()).isNull();
+ }
+
+}
diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java b/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java
index 6da6fcf8a8..1805788007 100644
--- a/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java
+++ b/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java
@@ -38,7 +38,6 @@ import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
-import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.RestApiCallResponseMsgProto;
import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg;
@@ -81,7 +80,7 @@ public interface TbClusterService extends TbQueueClusterService {
void pushNotificationToTransport(String targetServiceId, ToTransportMsg response, TbQueueCallback callback);
- void pushMsgToCalculatedFields(TenantId tenantId, EntityId entityId, TransportProtos.ToCalculatedFieldMsg msg, TbQueueCallback callback);
+ void pushMsgToCalculatedFields(TenantId tenantId, EntityId entityId, ToCalculatedFieldMsg msg, TbQueueCallback callback);
void pushMsgToCalculatedFields(TopicPartitionInfo tpi, UUID msgId, ToCalculatedFieldMsg msg, TbQueueCallback callback);
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java
index 3ad12048cf..55abc80998 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java
@@ -29,6 +29,8 @@ public interface AiModelService extends EntityDaoService {
AiModel save(AiModel model);
+ AiModel save(AiModel model, boolean doValidate);
+
Optional findAiModelById(TenantId tenantId, AiModelId modelId);
PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink);
@@ -37,6 +39,8 @@ public interface AiModelService extends EntityDaoService {
FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId);
+ Optional findAiModelByTenantIdAndName(TenantId tenantId, String name);
+
boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId);
}
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java
index c22c9c4140..59c17fb4bd 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java
@@ -17,6 +17,7 @@ package org.thingsboard.server.dao.asset;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.EntitySubtype;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetInfo;
@@ -48,6 +49,8 @@ public interface AssetService extends EntityDaoService {
Asset saveAsset(Asset asset);
+ Asset saveAsset(Asset asset, NameConflictStrategy nameConflictStrategy);
+
Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, CustomerId customerId);
Asset unassignAssetFromCustomer(TenantId tenantId, AssetId assetId);
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java
index d478e2099a..1cd5ff9ff1 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java
@@ -17,6 +17,7 @@ package org.thingsboard.server.dao.customer;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.Customer;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
@@ -37,6 +38,8 @@ public interface CustomerService extends EntityDaoService {
Customer saveCustomer(Customer customer);
+ Customer saveCustomer(Customer customer, NameConflictStrategy nameConflictStrategy);
+
void deleteCustomer(TenantId tenantId, CustomerId customerId);
Customer findOrCreatePublicCustomer(TenantId tenantId);
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java
index 9eb258f182..759ab3a708 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java
@@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.DeviceInfoFilter;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceTransportType;
import org.thingsboard.server.common.data.EntitySubtype;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.device.DeviceSearchQuery;
import org.thingsboard.server.common.data.id.CustomerId;
@@ -58,8 +59,12 @@ public interface DeviceService extends EntityDaoService {
Device saveDeviceWithAccessToken(Device device, String accessToken);
+ Device saveDeviceWithAccessToken(Device device, String accessToken, NameConflictStrategy nameConflictStrategy);
+
Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials);
+ Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy);
+
Device saveDevice(ProvisionRequest provisionRequest, DeviceProfile profile);
Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, CustomerId customerId);
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java
index 6e557106f8..b039dbafd1 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java
@@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.EntityViewInfo;
+import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EdgeId;
@@ -38,6 +39,8 @@ public interface EntityViewService extends EntityDaoService {
EntityView saveEntityView(EntityView entityView);
+ EntityView saveEntityView(EntityView entityView, NameConflictStrategy nameConflictStrategy);
+
EntityView saveEntityView(EntityView entityView, boolean doValidate);
EntityView assignEntityViewToCustomer(TenantId tenantId, EntityViewId entityViewId, CustomerId customerId);
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java
index 1db5739e94..a0bc9a72e6 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java
@@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationInfo;
+import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.EntityRelationsQuery;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.rule.RuleChainType;
@@ -83,6 +84,8 @@ public interface RelationService {
List findRuleNodeToRuleChainRelations(TenantId tenantId, RuleChainType ruleChainType, int limit);
+ ListenableFuture> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery);
+
// TODO: This method may be useful for some validations in the future
// ListenableFuture checkRecursiveRelation(EntityId from, EntityId to);
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java
index ee187db46b..65211ec17a 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java
@@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
@@ -46,6 +47,8 @@ public interface ResourceService extends EntityDaoService {
byte[] getResourceData(TenantId tenantId, TbResourceId resourceId);
+ TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId);
+
ResourceExportData exportResource(TbResourceInfo resourceInfo);
List exportResources(TenantId tenantId, Collection resources);
@@ -90,4 +93,6 @@ public interface ResourceService extends EntityDaoService {
TbResource createOrUpdateSystemResource(ResourceType resourceType, ResourceSubType resourceSubType, String resourceKey, byte[] data);
+ List findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds);
+
}
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java
new file mode 100644
index 0000000000..23485684af
--- /dev/null
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java
@@ -0,0 +1,28 @@
+/**
+ * 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.resource;
+
+import com.google.common.util.concurrent.FluentFuture;
+import org.thingsboard.server.common.data.TbResourceDataInfo;
+import org.thingsboard.server.common.data.id.TbResourceId;
+import org.thingsboard.server.common.data.id.TenantId;
+
+public interface TbResourceDataCache {
+
+ FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId);
+
+ void evictResourceData(TenantId tenantId, TbResourceId resourceId);
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java
new file mode 100644
index 0000000000..94edd4fa01
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java
@@ -0,0 +1,29 @@
+/**
+ * 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.common.data;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+@Data
+@EqualsAndHashCode
+@AllArgsConstructor
+@NoArgsConstructor
+public class GeneralFileDescriptor {
+ private String mediaType;
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java
similarity index 85%
rename from application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java
rename to common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java
index 6505dae581..1685dbc933 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java
@@ -13,7 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.thingsboard.server.service.cf;
+package org.thingsboard.server.common.data;
+
+public enum NameConflictPolicy {
+
+ FAIL,
+ UNIQUIFY;
-public interface CalculatedFieldInitService {
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java
new file mode 100644
index 0000000000..9624b8c978
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java
@@ -0,0 +1,25 @@
+/**
+ * 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.common.data;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema
+public record NameConflictStrategy(NameConflictPolicy policy, String separator, UniquifyStrategy uniquifyStrategy) {
+
+ public static final NameConflictStrategy DEFAULT = new NameConflictStrategy(NameConflictPolicy.FAIL, null, null);
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java
index 77b17198e9..f7579b6878 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java
@@ -25,7 +25,8 @@ public enum ResourceType {
PKCS_12("application/x-pkcs12", false, false),
JS_MODULE("application/javascript", true, true),
IMAGE(null, true, true),
- DASHBOARD("application/json", true, true);
+ DASHBOARD("application/json", true, true),
+ GENERAL(null, false, true);
@Getter
private final String mediaType;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
index fe3eb4e4d8..f83a812529 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
@@ -38,5 +38,7 @@ public class SystemParams {
String calculatedFieldDebugPerTenantLimitsConfiguration;
long maxArgumentsPerCF;
long maxDataPointsPerRollingArg;
+ int minAllowedScheduledUpdateIntervalInSecForCF;
+ int maxRelationLevelPerCfArgument;
TrendzSettings trendzSettings;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java
index ba37067106..457d30e263 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.common.data;
import com.fasterxml.jackson.annotation.JsonGetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSetter;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -86,6 +87,11 @@ public class TbResource extends TbResourceInfo {
.orElse(null);
}
+ @JsonIgnore
+ public TbResourceDataInfo toResourceDataInfo() {
+ return new TbResourceDataInfo(data, getDescriptor());
+ }
+
@Override
public String toString() {
return super.toString();
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java
new file mode 100644
index 0000000000..039478470d
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java
@@ -0,0 +1,31 @@
+/**
+ * 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.common.data;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class TbResourceDataInfo {
+
+ private byte[] data;
+ private JsonNode descriptor;
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java
index edc5a2f539..76945a97ed 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java
@@ -17,7 +17,6 @@ package org.thingsboard.server.common.data;
import lombok.Builder;
import lombok.Data;
-import org.thingsboard.server.common.data.id.HasId;
import java.util.List;
import java.util.Map;
@@ -27,6 +26,6 @@ import java.util.Map;
public class TbResourceDeleteResult {
private boolean success;
- private Map>> references;
+ private Map> references;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java
new file mode 100644
index 0000000000..5c9841f096
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java
@@ -0,0 +1,23 @@
+/**
+ * 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.common.data;
+
+public enum UniquifyStrategy {
+
+ RANDOM,
+ INCREMENTAL;
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java
index 2cc17e4553..73e6557fb5 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java
@@ -22,7 +22,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "status",
- include = JsonTypeInfo.As.PROPERTY,
+ include = JsonTypeInfo.As.EXISTING_PROPERTY,
visible = true
)
@JsonSubTypes({
@@ -51,9 +51,7 @@ public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatRes
}
record Failure(
- @Schema(
- description = "A string containing details about the failure"
- )
+ @Schema(description = "A string containing details about the failure")
String errorDetails
) implements TbChatResponse {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java
index bfaa29a6e3..f18429e7cf 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java
@@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelCon
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
@@ -34,6 +35,7 @@ import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig
import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@JsonTypeInfo(
@@ -50,7 +52,8 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "MISTRAL_AI"),
@JsonSubTypes.Type(value = AnthropicChatModelConfig.class, name = "ANTHROPIC"),
@JsonSubTypes.Type(value = AmazonBedrockChatModelConfig.class, name = "AMAZON_BEDROCK"),
- @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS")
+ @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS"),
+ @JsonSubTypes.Type(value = OllamaChatModelConfig.class, name = "OLLAMA")
})
public interface AiModelConfig {
@@ -69,7 +72,8 @@ public interface AiModelConfig {
@JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"),
@JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC"),
@JsonSubTypes.Type(value = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK"),
- @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS")
+ @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS"),
+ @JsonSubTypes.Type(value = OllamaProviderConfig.class, name = "OLLAMA")
})
AiProviderConfig providerConfig();
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
index 2bc28cfce0..49126c1861 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
@@ -24,7 +24,7 @@ public sealed interface AiChatModelConfig> extend
permits
OpenAiChatModelConfig, AzureOpenAiChatModelConfig, GoogleAiGeminiChatModelConfig,
GoogleVertexAiGeminiChatModelConfig, MistralAiChatModelConfig, AnthropicChatModelConfig,
- AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig {
+ AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig, OllamaChatModelConfig {
ChatModel configure(Langchain4jChatModelConfigurer configurer);
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
index 2bb4de5aa8..d2ab72086a 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
@@ -33,7 +33,7 @@ public record AmazonBedrockChatModelConfig(
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
index 69b5578fb3..6d505f75a6 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
@@ -34,7 +34,7 @@ public record AnthropicChatModelConfig(
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@PositiveOrZero Integer topK,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
index 47e7e96c37..f70f2af539 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
@@ -35,7 +35,7 @@ public record AzureOpenAiChatModelConfig(
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
index b509254f77..0aafd72197 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
@@ -35,7 +35,7 @@ public record GitHubModelsChatModelConfig(
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
index fe11a11460..b5c3d4263d 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
@@ -36,7 +36,7 @@ public record GoogleAiGeminiChatModelConfig(
@PositiveOrZero Integer topK,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
index 609e14f86e..944963ee27 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
@@ -36,7 +36,7 @@ public record GoogleVertexAiGeminiChatModelConfig(
@PositiveOrZero Integer topK,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java
index c9c1bc3173..828256dcdc 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java
@@ -35,4 +35,6 @@ public interface Langchain4jChatModelConfigurer {
ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig);
+ ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig);
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
index f603e99c53..8f67d93398 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
@@ -35,7 +35,7 @@ public record MistralAiChatModelConfig(
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
new file mode 100644
index 0000000000..ea48670b63
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
@@ -0,0 +1,58 @@
+/**
+ * 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.common.data.ai.model.chat;
+
+import dev.langchain4j.model.chat.ChatModel;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import lombok.Builder;
+import lombok.With;
+import org.thingsboard.server.common.data.ai.provider.AiProvider;
+import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
+
+@Builder
+public record OllamaChatModelConfig(
+ @NotNull @Valid OllamaProviderConfig providerConfig,
+ @NotBlank String modelId,
+ @PositiveOrZero Double temperature,
+ @Positive @Max(1) Double topP,
+ @PositiveOrZero Integer topK,
+ Integer contextLength,
+ Integer maxOutputTokens,
+ @With @Positive Integer timeoutSeconds,
+ @With @PositiveOrZero Integer maxRetries
+) implements AiChatModelConfig {
+
+ @Override
+ public AiProvider provider() {
+ return AiProvider.OLLAMA;
+ }
+
+ @Override
+ public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
+ return configurer.configureChatModel(this);
+ }
+
+ @Override
+ public boolean supportsJsonMode() {
+ return true;
+ }
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
index 00b5115d7d..23db9accc2 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
@@ -35,7 +35,7 @@ public record OpenAiChatModelConfig(
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
- @Positive Integer maxOutputTokens,
+ Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java
index d0a5bd0510..a9a6af4de8 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java
@@ -24,6 +24,7 @@ public enum AiProvider {
MISTRAL_AI,
ANTHROPIC,
AMAZON_BEDROCK,
- GITHUB_MODELS
+ GITHUB_MODELS,
+ OLLAMA
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java
index bd32c88efb..5423b24410 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java
@@ -19,4 +19,4 @@ public sealed interface AiProviderConfig
permits
OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig,
GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig,
- AmazonBedrockProviderConfig, GitHubModelsProviderConfig {}
+ AmazonBedrockProviderConfig, GitHubModelsProviderConfig, OllamaProviderConfig {}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java
new file mode 100644
index 0000000000..39bb57834c
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java
@@ -0,0 +1,48 @@
+/**
+ * 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.common.data.ai.provider;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+
+public record OllamaProviderConfig(
+ @NotNull String baseUrl,
+ @NotNull @Valid OllamaAuth auth
+) implements AiProviderConfig {
+
+ @JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "type"
+ )
+ @JsonSubTypes({
+ @JsonSubTypes.Type(value = OllamaAuth.None.class, name = "NONE"),
+ @JsonSubTypes.Type(value = OllamaAuth.Basic.class, name = "BASIC"),
+ @JsonSubTypes.Type(value = OllamaAuth.Token.class, name = "TOKEN")
+ })
+ public sealed interface OllamaAuth {
+
+ record None() implements OllamaAuth {}
+
+ record Basic(@NotNull String username, @NotNull String password) implements OllamaAuth {}
+
+ record Token(@NotNull String token) implements OllamaAuth {}
+
+ }
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java
index 09ffda837b..f7864db6e3 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java
@@ -15,8 +15,32 @@
*/
package org.thingsboard.server.common.data.ai.provider;
-import jakarta.validation.constraints.NotNull;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import jakarta.validation.constraints.AssertTrue;
+import lombok.Builder;
+import org.apache.commons.lang3.StringUtils;
+import java.util.Objects;
+
+@Builder
public record OpenAiProviderConfig(
- @NotNull String apiKey
-) implements AiProviderConfig {}
+ String baseUrl,
+ String apiKey
+) implements AiProviderConfig {
+
+ public static final String OPENAI_OFFICIAL_BASE_URL = "https://api.openai.com/v1";
+
+ public OpenAiProviderConfig {
+ baseUrl = Objects.requireNonNullElse(baseUrl, OPENAI_OFFICIAL_BASE_URL);
+ }
+
+ @JsonIgnore
+ @AssertTrue(message = "API key is required when using the official OpenAI API")
+ public boolean isValid() {
+ if (baseUrl.equals(OPENAI_OFFICIAL_BASE_URL)) {
+ return StringUtils.isNotBlank(apiKey);
+ }
+ return true;
+ }
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java
index 7a80a09891..b316a2ef50 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java
@@ -38,6 +38,11 @@ public class AlarmInfo extends Alarm {
@Schema(description = "Alarm originator label", example = "Thermostat label")
private String originatorLabel;
+ @Getter
+ @Setter
+ @Schema(description = "Originator display name", example = "Thermostat")
+ private String originatorDisplayName;
+
@Getter
@Setter
@Schema(description = "Alarm assignee")
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
index acef67a041..d4dd2c5812 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
@@ -17,6 +17,6 @@ package org.thingsboard.server.common.data.cf;
public enum CalculatedFieldType {
- SIMPLE, SCRIPT
+ SIMPLE, SCRIPT, GEOFENCING
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java
index e7daa70b1b..52935c3411 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java
@@ -26,10 +26,15 @@ public class Argument {
@Nullable
private EntityId refEntityId;
+ private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration;
private ReferencedEntityKey refEntityKey;
private String defaultValue;
private Integer limit;
private Long timeWindow;
+ public boolean hasDynamicSource() {
+ return refDynamicSourceConfiguration != null;
+ }
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java
new file mode 100644
index 0000000000..225278e776
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.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.server.common.data.cf.configuration;
+
+import java.util.Map;
+
+public interface ArgumentsBasedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
+
+ Map getArguments();
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java
index 8227ff4603..c270874605 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java
@@ -27,7 +27,7 @@ import java.util.Objects;
import java.util.stream.Collectors;
@Data
-public abstract class BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration {
+public abstract class BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration {
protected Map arguments;
protected String expression;
@@ -42,20 +42,13 @@ public abstract class BaseCalculatedFieldConfiguration implements CalculatedFiel
}
@Override
- public List buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId) {
- return getReferencedEntities().stream()
- .filter(referencedEntity -> !referencedEntity.equals(cfEntityId))
- .map(referencedEntityId -> buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId))
- .collect(Collectors.toList());
- }
-
- @Override
- public CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) {
- CalculatedFieldLink link = new CalculatedFieldLink();
- link.setTenantId(tenantId);
- link.setEntityId(referencedEntityId);
- link.setCalculatedFieldId(calculatedFieldId);
- return link;
+ public void validate() {
+ if (arguments.containsKey("ctx")) {
+ throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used.");
+ }
+ if (arguments.values().stream().anyMatch(Argument::hasDynamicSource)) {
+ throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support dynamic source configuration!");
+ }
}
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java
new file mode 100644
index 0000000000..dc52287f3e
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java
@@ -0,0 +1,22 @@
+/**
+ * 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.common.data.cf.configuration;
+
+public enum CFArgumentDynamicSourceType {
+
+ RELATION_PATH_QUERY
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
index ad3d4373ad..972a3e0ee9 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
@@ -21,12 +21,14 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
+import java.util.Collections;
import java.util.List;
-import java.util.Map;
+import java.util.stream.Collectors;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
@@ -35,7 +37,8 @@ import java.util.Map;
)
@JsonSubTypes({
@JsonSubTypes.Type(value = SimpleCalculatedFieldConfiguration.class, name = "SIMPLE"),
- @JsonSubTypes.Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT")
+ @JsonSubTypes.Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT"),
+ @JsonSubTypes.Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface CalculatedFieldConfiguration {
@@ -43,19 +46,28 @@ public interface CalculatedFieldConfiguration {
@JsonIgnore
CalculatedFieldType getType();
- Map getArguments();
-
- String getExpression();
-
- void setExpression(String expression);
-
Output getOutput();
+ void validate();
+
@JsonIgnore
- List getReferencedEntities();
+ default List getReferencedEntities() {
+ return Collections.emptyList();
+ }
- List buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId);
+ default CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) {
+ CalculatedFieldLink link = new CalculatedFieldLink();
+ link.setTenantId(tenantId);
+ link.setEntityId(referencedEntityId);
+ link.setCalculatedFieldId(calculatedFieldId);
+ return link;
+ }
- CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId);
+ default List buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId) {
+ return getReferencedEntities().stream()
+ .filter(referencedEntity -> !referencedEntity.equals(cfEntityId))
+ .map(referencedEntityId -> buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId))
+ .collect(Collectors.toList());
+ }
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java
new file mode 100644
index 0000000000..ff746161e9
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java
@@ -0,0 +1,39 @@
+/**
+ * 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.common.data.cf.configuration;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+@JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "type"
+)
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = RelationPathQueryDynamicSourceConfiguration.class, name = "RELATION_PATH_QUERY")
+})
+@JsonIgnoreProperties(ignoreUnknown = true)
+public interface CfArgumentDynamicSourceConfiguration {
+
+ @JsonIgnore
+ CFArgumentDynamicSourceType getType();
+
+ default void validate() {}
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ExpressionBasedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ExpressionBasedCalculatedFieldConfiguration.java
new file mode 100644
index 0000000000..17e6a0ba80
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ExpressionBasedCalculatedFieldConfiguration.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.server.common.data.cf.configuration;
+
+public interface ExpressionBasedCalculatedFieldConfiguration extends ArgumentsBasedCalculatedFieldConfiguration {
+
+ String getExpression();
+
+ void setExpression(String expression);
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java
new file mode 100644
index 0000000000..dc92ff3685
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java
@@ -0,0 +1,71 @@
+/**
+ * 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.common.data.cf.configuration;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Data;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.relation.EntityRelation;
+import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+import org.thingsboard.server.common.data.relation.RelationPathLevel;
+import org.thingsboard.server.common.data.util.CollectionsUtil;
+
+import java.util.List;
+import java.util.NoSuchElementException;
+
+@Data
+public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration {
+
+ private List levels;
+
+ @Override
+ public CFArgumentDynamicSourceType getType() {
+ return CFArgumentDynamicSourceType.RELATION_PATH_QUERY;
+ }
+
+ @Override
+ public void validate() {
+ if (CollectionsUtil.isEmpty(levels)) {
+ throw new IllegalArgumentException("At least one relation level must be specified!");
+ }
+ levels.forEach(RelationPathLevel::validate);
+ }
+
+ public List resolveEntityIds(List relations) {
+ EntitySearchDirection lastLevelDirection = getLastLevel().direction();
+ return switch (lastLevelDirection) {
+ case FROM -> relations.stream().map(EntityRelation::getTo).toList();
+ case TO -> relations.stream().map(EntityRelation::getFrom).toList();
+ };
+ }
+
+ public void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) {
+ if (levels.size() > maxAllowedRelationLevel) {
+ throw new IllegalArgumentException("Max relation level is greater than configured " +
+ "maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName);
+ }
+ }
+
+ public EntityRelationPathQuery toRelationPathQuery(EntityId entityId) {
+ return new EntityRelationPathQuery(entityId, levels);
+ }
+
+ private RelationPathLevel getLastLevel() {
+ return levels.get(levels.size() - 1);
+ }
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java
new file mode 100644
index 0000000000..d0c5786f62
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java
@@ -0,0 +1,32 @@
+/**
+ * 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.common.data.cf.configuration;
+
+public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
+
+ boolean isScheduledUpdateEnabled();
+
+ int getScheduledUpdateInterval();
+
+ void setScheduledUpdateInterval(int interval);
+
+ default void validate(long minAllowedScheduledUpdateInterval) {
+ if (getScheduledUpdateInterval() < minAllowedScheduledUpdateInterval) {
+ throw new IllegalArgumentException("Scheduled update interval is less than configured " +
+ "minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval);
+ }
+ }
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java
index c2dde43b8e..0d38059a45 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java
@@ -21,7 +21,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@Data
@EqualsAndHashCode(callSuper = true)
-public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration {
+public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration {
@Override
public CalculatedFieldType getType() {
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java
index 86c7b9e9b6..471bac3653 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java
@@ -21,7 +21,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@Data
@EqualsAndHashCode(callSuper = true)
-public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration {
+public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration {
private boolean useLatestTs;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java
new file mode 100644
index 0000000000..9ea5c19e8c
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java
@@ -0,0 +1,57 @@
+/**
+ * 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.common.data.cf.configuration.geofencing;
+
+
+import lombok.Data;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+
+import java.util.Map;
+
+@Data
+public class EntityCoordinates {
+
+ public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
+ public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
+
+ private final String latitudeKeyName;
+ private final String longitudeKeyName;
+
+ public void validate() {
+ if (StringUtils.isBlank(latitudeKeyName)) {
+ throw new IllegalArgumentException("Entity coordinates latitude key name must be specified!");
+ }
+ if (StringUtils.isBlank(longitudeKeyName)) {
+ throw new IllegalArgumentException("Entity coordinates longitude key name must be specified!");
+ }
+ }
+
+ public Map toArguments() {
+ return Map.of(
+ ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument(latitudeKeyName),
+ ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument(longitudeKeyName)
+ );
+ }
+
+ private Argument toArgument(String keyName) {
+ var argument = new Argument();
+ argument.setRefEntityKey(new ReferencedEntityKey(keyName, ArgumentType.TS_LATEST, null));
+ return argument;
+ }
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java
new file mode 100644
index 0000000000..b331abc50b
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java
@@ -0,0 +1,78 @@
+/**
+ * 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.common.data.cf.configuration.geofencing;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Data;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.Output;
+import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.id.EntityId;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Data
+public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration {
+
+ private EntityCoordinates entityCoordinates;
+ private Map zoneGroups;
+
+ private boolean scheduledUpdateEnabled;
+ private int scheduledUpdateInterval;
+
+ private Output output;
+
+ @Override
+ public CalculatedFieldType getType() {
+ return CalculatedFieldType.GEOFENCING;
+ }
+
+ @Override
+ @JsonIgnore
+ public Map getArguments() {
+ Map args = new HashMap<>(entityCoordinates.toArguments());
+ zoneGroups.forEach((zgName, zgConfig) -> args.put(zgName, zgConfig.toArgument()));
+ return args;
+ }
+
+ @Override
+ public List getReferencedEntities() {
+ return zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList();
+ }
+
+ @Override
+ public Output getOutput() {
+ return output;
+ }
+
+ @Override
+ public void validate() {
+ if (entityCoordinates == null) {
+ throw new IllegalArgumentException("Geofencing calculated field entity coordinates must be specified!");
+ }
+ entityCoordinates.validate();
+ if (zoneGroups == null || zoneGroups.isEmpty()) {
+ throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!");
+ }
+ zoneGroups.forEach((key, value) -> value.validate(key));
+ }
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingEvent.java
new file mode 100644
index 0000000000..a6ee0cfcd6
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingEvent.java
@@ -0,0 +1,19 @@
+/**
+ * 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.common.data.cf.configuration.geofencing;
+
+public sealed interface GeofencingEvent
+ permits GeofencingTransitionEvent, GeofencingPresenceStatus { }
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingPresenceStatus.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingPresenceStatus.java
new file mode 100644
index 0000000000..38977cb650
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingPresenceStatus.java
@@ -0,0 +1,25 @@
+/**
+ * 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.common.data.cf.configuration.geofencing;
+
+import lombok.Getter;
+
+@Getter
+public enum GeofencingPresenceStatus implements GeofencingEvent {
+
+ INSIDE, OUTSIDE;
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingReportStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingReportStrategy.java
new file mode 100644
index 0000000000..a7937bb93c
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingReportStrategy.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.server.common.data.cf.configuration.geofencing;
+
+public enum GeofencingReportStrategy {
+
+ REPORT_TRANSITION_EVENTS_ONLY,
+ REPORT_PRESENCE_STATUS_ONLY,
+ REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingTransitionEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingTransitionEvent.java
new file mode 100644
index 0000000000..d7cf996fa7
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingTransitionEvent.java
@@ -0,0 +1,20 @@
+/**
+ * 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.common.data.cf.configuration.geofencing;
+
+public enum GeofencingTransitionEvent implements GeofencingEvent {
+ ENTERED, LEFT
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java
new file mode 100644
index 0000000000..2feb6e49d0
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.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.server.common.data.cf.configuration.geofencing;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+import org.springframework.lang.Nullable;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.CfArgumentDynamicSourceConfiguration;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ZoneGroupConfiguration {
+
+ @Nullable
+ private EntityId refEntityId;
+ private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration;
+
+ private final String perimeterKeyName;
+
+ private final GeofencingReportStrategy reportStrategy;
+ private final boolean createRelationsWithMatchedZones;
+
+ private String relationType;
+ private EntitySearchDirection direction;
+
+ public void validate(String name) {
+ if (EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY.equals(name) || EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY.equals(name)) {
+ throw new IllegalArgumentException("Name '" + name + "' is reserved and cannot be used for zone group!");
+ }
+ if (StringUtils.isBlank(perimeterKeyName)) {
+ throw new IllegalArgumentException("Perimeter key name must be specified for '" + name + "' zone group!");
+ }
+ if (reportStrategy == null) {
+ throw new IllegalArgumentException("Report strategy must be specified for '" + name + "' zone group!");
+ }
+ if (hasDynamicSource()) {
+ refDynamicSourceConfiguration.validate();
+ }
+ if (!createRelationsWithMatchedZones) {
+ return;
+ }
+ if (StringUtils.isBlank(relationType)) {
+ throw new IllegalArgumentException("Relation type must be specified for '" + name + "' zone group!");
+ }
+ if (direction == null) {
+ throw new IllegalArgumentException("Relation direction must be specified for '" + name + "' zone group!");
+ }
+ }
+
+ public boolean hasDynamicSource() {
+ return refDynamicSourceConfiguration != null;
+ }
+
+ @JsonIgnore
+ public boolean isCfEntitySource(EntityId cfEntityId) {
+ if (refEntityId == null && refDynamicSourceConfiguration == null) {
+ return true;
+ }
+ return refEntityId != null && refEntityId.equals(cfEntityId);
+ }
+
+ @JsonIgnore
+ public boolean isLinkedCfEntitySource(EntityId cfEntityId) {
+ return refEntityId != null && !refEntityId.equals(cfEntityId);
+ }
+
+ public Argument toArgument() {
+ var argument = new Argument();
+ argument.setRefEntityId(refEntityId);
+ argument.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration);
+ argument.setRefEntityKey(new ReferencedEntityKey(perimeterKeyName, ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ return argument;
+ }
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java
index 0d5c3f34ad..fadb44207a 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java
@@ -47,7 +47,8 @@ public enum EdgeEventType {
TB_RESOURCE(true, EntityType.TB_RESOURCE),
OAUTH2_CLIENT(true, EntityType.OAUTH2_CLIENT),
DOMAIN(true, EntityType.DOMAIN),
- CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD);
+ CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD),
+ AI_MODEL(true, EntityType.AI_MODEL);
private final boolean allEdgesRelated;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java
index 6f4c7643c0..31e0f4118c 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java
@@ -37,7 +37,7 @@ public class UserFields extends AbstractEntityFields {
@Override
public String getName() {
- return super.getEmail();
+ return getEmail();
}
public UserFields(UUID id, long createdTime, UUID tenantId, UUID customerId,
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java
index fc1c1cd1a9..09d34ff10b 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java
@@ -113,6 +113,7 @@ public class EntityIdFactory {
case OAUTH2_CLIENT -> new OAuth2ClientId(uuid);
case DOMAIN -> new DomainId(uuid);
case CALCULATED_FIELD -> new CalculatedFieldId(uuid);
+ case AI_MODEL -> new AiModelId(uuid);
default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!");
};
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java
index 2190972d7e..b023ba89c3 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java
@@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.MobileAppId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.mobile.layout.MobileLayoutConfig;
import org.thingsboard.server.common.data.validation.Length;
+import org.thingsboard.server.common.data.validation.NoXss;
@EqualsAndHashCode(callSuper = true)
@Data
@@ -40,9 +41,11 @@ public class MobileAppBundle extends BaseData implements HasT
private TenantId tenantId;
@Schema(description = "Application bundle title. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
+ @NoXss
@Length(fieldName = "title")
private String title;
@Schema(description = "Application bundle description.")
+ @NoXss
@Length(fieldName = "description")
private String description;
@Schema(description = "Android application id")
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java
index 81b5e0ccfb..fe87e0f966 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java
@@ -62,6 +62,7 @@ public class NotificationRule extends BaseData implements Ha
@Valid
private NotificationRuleRecipientsConfig recipientsConfig;
+ @Valid
private NotificationRuleConfig additionalConfig;
private NotificationRuleId externalId;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java
index 9103086b7c..013c0ae662 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java
@@ -16,12 +16,14 @@
package org.thingsboard.server.common.data.notification.rule;
import lombok.Data;
+import org.thingsboard.server.common.data.validation.NoXss;
import java.io.Serializable;
@Data
public class NotificationRuleConfig implements Serializable {
+ @NoXss
private String description;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java
new file mode 100644
index 0000000000..a81e3e0c86
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.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.server.common.data.relation;
+
+import org.thingsboard.server.common.data.id.EntityId;
+
+import java.util.List;
+
+public record EntityRelationPathQuery(EntityId rootEntityId, List levels) {
+
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java
new file mode 100644
index 0000000000..c28135204f
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java
@@ -0,0 +1,30 @@
+/**
+ * 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.common.data.relation;
+
+import org.thingsboard.server.common.data.StringUtils;
+
+public record RelationPathLevel(EntitySearchDirection direction, String relationType) {
+
+ public void validate() {
+ if (direction == null) {
+ throw new IllegalArgumentException("Direction must be specified!");
+ }
+ if (StringUtils.isBlank(relationType)) {
+ throw new IllegalArgumentException("Relation type must be specified!");
+ }
+ }
+}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
index 246fe46791..c6bd9a7f38 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
@@ -172,6 +172,10 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
private long maxCalculatedFieldsPerEntity = 5;
@Schema(example = "10")
private long maxArgumentsPerCF = 10;
+ @Schema(example = "3600")
+ private int minAllowedScheduledUpdateIntervalInSecForCF = 60;
+ @Schema(example = "10")
+ private int maxRelationLevelPerCfArgument = 10;
@Builder.Default
@Min(value = 1, message = "must be at least 1")
@Schema(example = "1000")
diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java
new file mode 100644
index 0000000000..d1108e9eed
--- /dev/null
+++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java
@@ -0,0 +1,38 @@
+/**
+ * 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.common.data.cf.configuration;
+
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ArgumentTest {
+
+ @Test
+ void validateShouldReturnFalseIfDynamicSourceConfigurationIsNull() {
+ var argument = new Argument();
+ assertThat(argument.hasDynamicSource()).isFalse();
+ }
+
+ @Test
+ void validateShouldReturnTrueIfDynamicSourceConfigurationIsNotNull() {
+ var argument = new Argument();
+ argument.setRefDynamicSourceConfiguration(new RelationPathQueryDynamicSourceConfiguration());
+ assertThat(argument.hasDynamicSource()).isTrue();
+ }
+
+}
diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java
new file mode 100644
index 0000000000..1a6ea5de3b
--- /dev/null
+++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java
@@ -0,0 +1,126 @@
+/**
+ * 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.common.data.cf.configuration;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.relation.EntityRelation;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+import org.thingsboard.server.common.data.relation.RelationPathLevel;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class RelationPathQueryDynamicSourceConfigurationTest {
+
+ @Test
+ void typeShouldBeRelationQuery() {
+ var cfg = new RelationPathQueryDynamicSourceConfiguration();
+ assertThat(cfg.getType()).isEqualTo(CFArgumentDynamicSourceType.RELATION_PATH_QUERY);
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ void validateShouldThrowWhenLevelsIsNull(List levels) {
+ var cfg = new RelationPathQueryDynamicSourceConfiguration();
+ cfg.setLevels(levels);
+
+ assertThatThrownBy(cfg::validate)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("At least one relation level must be specified!");
+ }
+
+ @Test
+ void validateShouldCallValidateForPathLevels() {
+ List levels = new ArrayList<>();
+
+ RelationPathLevel lvl1 = mock(RelationPathLevel.class);
+ RelationPathLevel lvl2 = mock(RelationPathLevel.class);
+ levels.add(lvl1);
+ levels.add(lvl2);
+
+ var cfg = new RelationPathQueryDynamicSourceConfiguration();
+ cfg.setLevels(levels);
+
+ assertThatCode(cfg::validate).doesNotThrowAnyException();
+
+ verify(lvl1).validate();
+ verify(lvl2).validate();
+ }
+
+ @Test
+ void resolveEntityIds_whenDirectionFROM_thenReturnsToIds() {
+ List levels = new ArrayList<>();
+
+ RelationPathLevel lvl1 = mock(RelationPathLevel.class);
+ RelationPathLevel lvl2 = mock(RelationPathLevel.class);
+ levels.add(lvl1);
+ levels.add(lvl2);
+
+ when(lvl2.direction()).thenReturn(EntitySearchDirection.FROM);
+
+ EntityRelation rel1 = mock(EntityRelation.class);
+ EntityRelation rel2 = mock(EntityRelation.class);
+
+ when(rel1.getTo()).thenReturn(mock(EntityId.class));
+ when(rel2.getTo()).thenReturn(mock(EntityId.class));
+
+ var cfg = new RelationPathQueryDynamicSourceConfiguration();
+ cfg.setLevels(levels);
+
+ var out = cfg.resolveEntityIds(List.of(rel1, rel2));
+
+ assertThat(out).containsExactly(rel1.getTo(), rel2.getTo());
+ }
+
+ @Test
+ void resolveEntityIds_whenDirectionTO_thenReturnsFromIds() {
+ List levels = new ArrayList<>();
+
+ RelationPathLevel lvl1 = mock(RelationPathLevel.class);
+ RelationPathLevel lvl2 = mock(RelationPathLevel.class);
+ levels.add(lvl1);
+ levels.add(lvl2);
+
+ when(lvl2.direction()).thenReturn(EntitySearchDirection.TO);
+
+ EntityRelation rel1 = mock(EntityRelation.class);
+ EntityRelation rel2 = mock(EntityRelation.class);
+
+ when(rel1.getFrom()).thenReturn(mock(EntityId.class));
+ when(rel2.getFrom()).thenReturn(mock(EntityId.class));
+
+ var cfg = new RelationPathQueryDynamicSourceConfiguration();
+ cfg.setLevels(levels);
+
+ var out = cfg.resolveEntityIds(List.of(rel1, rel2));
+
+ assertThat(out).containsExactly(rel1.getFrom(), rel2.getFrom());
+ }
+
+}
diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java
new file mode 100644
index 0000000000..3c0956bd08
--- /dev/null
+++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java
@@ -0,0 +1,54 @@
+/**
+ * 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.common.data.cf.configuration;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@ExtendWith(MockitoExtension.class)
+public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest {
+
+ @Test
+ void validateShouldThrowWhenScheduledUpdateIntervalIsSetButTimeUnitIsNotSupported() {
+ int scheduledUpdateInterval = 60;
+ int minAllowedInterval = scheduledUpdateInterval - 1;
+
+ var cfg = new GeofencingCalculatedFieldConfiguration();
+ cfg.setScheduledUpdateInterval(scheduledUpdateInterval);
+ assertThatCode(() -> cfg.validate(minAllowedInterval)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void validateShouldThrowWhenScheduledUpdateIntervalIsLessThanMinAllowedIntervalInTenantProfile() {
+ int minAllowedInterval = (int) TimeUnit.HOURS.toSeconds(2);
+
+ var cfg = new GeofencingCalculatedFieldConfiguration();
+ cfg.setScheduledUpdateInterval(1);
+
+ assertThatThrownBy(() -> cfg.validate(minAllowedInterval))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Scheduled update interval is less than configured " +
+ "minimum allowed interval in tenant profile: " + minAllowedInterval);
+ }
+
+}
diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java
new file mode 100644
index 0000000000..c5d627c3e6
--- /dev/null
+++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java
@@ -0,0 +1,80 @@
+/**
+ * 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.common.data.cf.configuration.geofencing;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
+
+public class EntityCoordinatesTest {
+
+ @ParameterizedTest
+ @ValueSource(strings = " ")
+ @NullAndEmptySource
+ void validateShouldThrowWhenLatitudeCoordinateIsNullEmptyOrBlank(String latitudeKey) {
+ var entityCoordinates = new EntityCoordinates(latitudeKey, "longitude");
+ assertThatThrownBy(entityCoordinates::validate)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Entity coordinates latitude key name must be specified!");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = " ")
+ @NullAndEmptySource
+ void validateShouldThrowWhenLongitudeCoordinateIsNullEmptyOrBlank(String longitudeKey) {
+ var entityCoordinates = new EntityCoordinates("latitude", longitudeKey);
+ assertThatThrownBy(entityCoordinates::validate)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Entity coordinates longitude key name must be specified!");
+ }
+
+ @Test
+ void validateShouldPassOnMinimalValidConfig() {
+ var entityCoordinates = new EntityCoordinates("latitude", "longitude");
+ assertThatCode(entityCoordinates::validate).doesNotThrowAnyException();
+ }
+
+ @Test
+ void validateToArgumentsMethodCallWithoutRefEntityId() {
+ var entityCoordinates = new EntityCoordinates("xPos", "yPos");
+
+ var arguments = entityCoordinates.toArguments();
+ assertThat(arguments).isNotNull().hasSize(2);
+
+ Argument latitudeArgument = arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY);
+ assertThat(latitudeArgument).isNotNull();
+ assertThat(latitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("xPos", ArgumentType.TS_LATEST, null));
+ assertThat(latitudeArgument.getRefEntityId()).isNull();
+ assertThat(latitudeArgument.getRefDynamicSourceConfiguration()).isNull();
+
+ Argument longitudeArgument = arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY);
+ assertThat(longitudeArgument).isNotNull();
+ assertThat(longitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("yPos", ArgumentType.TS_LATEST, null));
+ assertThat(longitudeArgument.getRefEntityId()).isNull();
+ assertThat(longitudeArgument.getRefDynamicSourceConfiguration()).isNull();
+ }
+
+}
diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java
new file mode 100644
index 0000000000..c5fb1f8953
--- /dev/null
+++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java
@@ -0,0 +1,134 @@
+/**
+ * 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.common.data.cf.configuration.geofencing;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
+
+@ExtendWith(MockitoExtension.class)
+public class GeofencingCalculatedFieldConfigurationTest {
+
+ @Test
+ void typeShouldBeGeofencing() {
+ var cfg = new GeofencingCalculatedFieldConfiguration();
+ assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.GEOFENCING);
+ }
+
+ @Test
+ void validateShouldThrowWhenEntityCoordinatesNull() {
+ var cfg = new GeofencingCalculatedFieldConfiguration();
+ cfg.setEntityCoordinates(null);
+
+ assertThatThrownBy(cfg::validate)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Geofencing calculated field entity coordinates must be specified!");
+ }
+
+ @Test
+ void validateShouldThrowWhenZoneGroupsNull() {
+ var cfg = new GeofencingCalculatedFieldConfiguration();
+ cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
+ cfg.setZoneGroups(null);
+
+ assertThatThrownBy(cfg::validate)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!");
+ }
+
+ @Test
+ void validateShouldCallValidateOnEntityCoordinatesAndZoneGroups() {
+ var cfg = new GeofencingCalculatedFieldConfiguration();
+ EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
+ cfg.setEntityCoordinates(entityCoordinatesMock);
+ var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class);
+ cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfiguration));
+
+ cfg.validate();
+
+ verify(entityCoordinatesMock).validate();
+ verify(zoneGroupConfiguration).validate("someGroupName");
+ }
+
+ @Test
+ void validateShouldCallValidateOnEntityCoordinatesAndZoneGroupsWithoutAnyExceptions() {
+ var cfg = new GeofencingCalculatedFieldConfiguration();
+ EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
+ cfg.setEntityCoordinates(entityCoordinatesMock);
+ var zoneGroupConfigurationA = mock(ZoneGroupConfiguration.class);
+ var zoneGroupConfigurationB = mock(ZoneGroupConfiguration.class);
+
+ String zoneGroupAName = "zoneGroupA";
+ String zoneGroupBName = "zoneGroupB";
+
+ cfg.setZoneGroups(Map.of("zoneGroupA", zoneGroupConfigurationA, "zoneGroupB", zoneGroupConfigurationB));
+
+ assertThatCode(cfg::validate).doesNotThrowAnyException();
+
+ verify(entityCoordinatesMock).validate();
+ verify(zoneGroupConfigurationA).validate(zoneGroupAName);
+ verify(zoneGroupConfigurationB).validate(zoneGroupBName);
+ }
+
+ @Test
+ void testGetArgumentsOverride() {
+ var cfg = new GeofencingCalculatedFieldConfiguration();
+ cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
+ cfg.setZoneGroups(Map.of("allowedZones", new ZoneGroupConfiguration("perimeter", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false)));
+
+ Map arguments = cfg.getArguments();
+
+ assertThat(arguments).isNotNull().hasSize(3);
+ assertThat(arguments).containsKeys(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, "allowedZones");
+
+ Argument latitudeArgument = arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY);
+ assertThat(latitudeArgument).isNotNull();
+ assertThat(latitudeArgument.getRefDynamicSourceConfiguration()).isNull();
+ assertThat(latitudeArgument.getRefEntityId()).isNull();
+ assertThat(latitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ArgumentType.TS_LATEST, null));
+
+ Argument longitudeArgument = arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY);
+ assertThat(longitudeArgument).isNotNull();
+ assertThat(longitudeArgument.getRefDynamicSourceConfiguration()).isNull();
+ assertThat(longitudeArgument.getRefEntityId()).isNull();
+ assertThat(longitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, ArgumentType.TS_LATEST, null));
+
+ Argument allowedZonesArgument = arguments.get("allowedZones");
+ assertThat(allowedZonesArgument).isNotNull();
+ assertThat(allowedZonesArgument.getRefDynamicSourceConfiguration()).isNull();
+ assertThat(allowedZonesArgument.getRefEntityId()).isNull();
+ assertThat(allowedZonesArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ }
+
+}
diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java
new file mode 100644
index 0000000000..beb4639a31
--- /dev/null
+++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java
@@ -0,0 +1,123 @@
+/**
+ * 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.common.data.cf.configuration.geofencing;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
+import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
+import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
+import org.thingsboard.server.common.data.relation.EntityRelation;
+import org.thingsboard.server.common.data.relation.EntitySearchDirection;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
+
+public class ZoneGroupConfigurationTest {
+
+ @ParameterizedTest
+ @ValueSource(strings = {EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY, EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY})
+ void validateShouldThrowWhenUsedReservedEntityCoordinateNames(String name) {
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ assertThatThrownBy(() -> zoneGroupConfiguration.validate(name))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Name '" + name + "' is reserved and cannot be used for zone group!");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = " ")
+ @NullAndEmptySource
+ void validateShouldThrowWhenPerimeterKeyNameIsNullEmptyOrBlank(String perimeterKeyName) {
+ var zoneGroupConfiguration = new ZoneGroupConfiguration(perimeterKeyName, REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Perimeter key name must be specified for 'allowedZonesGroup' zone group!");
+ }
+
+ @Test
+ void validateShouldThrowWhenReportStrategyIsNull() {
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", null, false);
+ assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Report strategy must be specified for 'allowedZonesGroup' zone group!");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = " ")
+ @NullAndEmptySource
+ void validateShouldThrowWhenRelationCreationEnabledAndRelationTypeIsNullEmptyOrBlank(String relationType) {
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true);
+ zoneGroupConfiguration.setRelationType(relationType);
+ assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Relation type must be specified for 'allowedZonesGroup' zone group!");
+ }
+
+ @Test
+ void validateShouldThrowWhenRelationCreationEnabledAndDirectionIsNull() {
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true);
+ zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
+ zoneGroupConfiguration.setDirection(null);
+ assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Relation direction must be specified for 'allowedZonesGroup' zone group!");
+ }
+
+ @Test
+ void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationDisabledAndConfigValid() {
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ assertThatCode(() -> zoneGroupConfiguration.validate("allowedZonesGroup")).doesNotThrowAnyException();
+ }
+
+ @Test
+ void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationEnabledAndConfigValid() {
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true);
+ zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
+ zoneGroupConfiguration.setDirection(EntitySearchDirection.TO);
+ assertThatCode(() -> zoneGroupConfiguration.validate("allowedZonesGroup")).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNotNull() {
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationPathQueryDynamicSourceConfiguration());
+ assertThat(zoneGroupConfiguration.hasDynamicSource()).isTrue();
+ }
+
+ @Test
+ void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNull() {
+ var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class);
+ assertThat(zoneGroupConfiguration.getRefDynamicSourceConfiguration()).isNull();
+ assertThat(zoneGroupConfiguration.hasDynamicSource()).isFalse();
+ }
+
+
+ @Test
+ void validateToArgumentsMethodCallWithoutRefEntityId() {
+ var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
+ Argument zoneGroupArgument = zoneGroupConfiguration.toArgument();
+ assertThat(zoneGroupArgument).isNotNull();
+ assertThat(zoneGroupArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ }
+
+}
diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java
index 0eff7ad053..dbaef23431 100644
--- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java
+++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java
@@ -136,7 +136,7 @@ public class EdgeGrpcClient implements EdgeRpcClient {
.setConnectRequestMsg(ConnectRequestMsg.newBuilder()
.setEdgeRoutingKey(edgeKey)
.setEdgeSecret(edgeSecret)
- .setEdgeVersion(EdgeVersion.V_4_2_0)
+ .setEdgeVersion(EdgeVersion.V_4_3_0)
.setMaxInboundMessageSize(maxInboundMessageSize)
.build())
.build());
diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto
index dbda462a99..5a0aeac977 100644
--- a/common/edge-api/src/main/proto/edge.proto
+++ b/common/edge-api/src/main/proto/edge.proto
@@ -44,6 +44,7 @@ enum EdgeVersion {
V_4_0_0 = 10;
V_4_1_0 = 11;
V_4_2_0 = 12;
+ V_4_3_0 = 13;
V_LATEST = 999;
}
@@ -133,6 +134,12 @@ message CalculatedFieldUpdateMsg{
string entity = 4;
}
+message AiModelUpdateMsg{
+ UpdateMsgType msgType = 1;
+ int64 idMSB = 2;
+ int64 idLSB = 3;
+ string entity = 4;
+}
message EntityDataProto {
int64 entityIdMSB = 1;
@@ -441,6 +448,7 @@ message UplinkMsg {
repeated RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = 24;
repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 25;
repeated CalculatedFieldRequestMsg calculatedFieldRequestMsg = 26;
+ repeated AiModelUpdateMsg aiModelUpdateMsg = 27;
}
message UplinkResponseMsg {
@@ -491,4 +499,5 @@ message DownlinkMsg {
repeated NotificationTemplateUpdateMsg notificationTemplateUpdateMsg = 33;
repeated OAuth2DomainUpdateMsg oAuth2DomainUpdateMsg = 34;
repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 35;
+ repeated AiModelUpdateMsg aiModelUpdateMsg = 36;
}
diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java
index 21b9ff0c4f..b75ce6a315 100644
--- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java
+++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java
@@ -20,6 +20,7 @@ import lombok.Setter;
import lombok.ToString;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.edqs.fields.EntityFields;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.permission.QueryContext;
@@ -139,11 +140,32 @@ public abstract class BaseEntityData implements EntityDa
case "name" -> getEntityName();
case "ownerName" -> getOwnerName();
case "ownerType" -> getOwnerType();
+ case "displayName" -> getDisplayName();
case "entityType" -> Optional.ofNullable(getEntityType()).map(EntityType::name).orElse("");
default -> fields.getAsString(name);
};
}
+ public String getDisplayName(){
+ return switch (getEntityType()) {
+ case DEVICE, ASSET -> StringUtils.isNotBlank(fields.getLabel()) ? fields.getLabel() : fields.getName();
+ case USER -> {
+ boolean firstNameSet = StringUtils.isNotBlank(fields.getFirstName());
+ boolean lastNameSet = StringUtils.isNotBlank(fields.getLastName());
+ if(firstNameSet && lastNameSet) {
+ yield fields.getFirstName() + " " + fields.getLastName();
+ } else if(firstNameSet) {
+ yield fields.getFirstName();
+ } else if (lastNameSet) {
+ yield fields.getLastName();
+ } else {
+ yield fields.getEmail();
+ }
+ }
+ default -> fields.getName();
+ };
+ }
+
public String getEntityName() {
return getFields().getName();
}
diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java
index 3a3e5c5792..f47bfcb28a 100644
--- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java
+++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java
@@ -18,6 +18,7 @@ package org.thingsboard.server.edqs.data;
import lombok.ToString;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.edqs.fields.DeviceFields;
import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.edqs.DataPoint;
diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java
index e0805c27aa..5b8a241a1a 100644
--- a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java
+++ b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java
@@ -60,16 +60,4 @@ public class SchedulerUtils {
return LocalDate.now(UTC).with(TemporalAdjusters.firstDayOfNextMonth()).atStartOfDay(zoneId).toInstant().toEpochMilli();
}
- public static long getStartOfNextNextMonth() {
- return getStartOfNextNextMonth(UTC);
- }
-
- public static long getStartOfNextNextMonth(ZoneId zoneId) {
- return LocalDate.now(UTC).with(firstDayOfNextNextMonth()).atStartOfDay(zoneId).toInstant().toEpochMilli();
- }
-
- public static TemporalAdjuster firstDayOfNextNextMonth() {
- return (temporal) -> temporal.with(DAY_OF_MONTH, 1).plus(2, MONTHS);
- }
-
}
diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java
index be407bd3db..f5a07cf07e 100644
--- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java
+++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java
@@ -1377,6 +1377,18 @@ public class ProtoUtils {
return TbMsg.fromProto(queueName, getTbMsgProto(ruleEngineMsg), callback);
}
+ public static TransportProtos.EntityIdProto toProto(EntityId entityId) {
+ return TransportProtos.EntityIdProto.newBuilder()
+ .setEntityIdMSB(getMsb(entityId))
+ .setEntityIdLSB(getLsb(entityId))
+ .setType(toProto(entityId.getEntityType()))
+ .build();
+ }
+
+ public static EntityId fromProto(TransportProtos.EntityIdProto entityIdProto) {
+ return EntityIdFactory.getByTypeAndUuid(fromProto(entityIdProto.getType()), new UUID(entityIdProto.getEntityIdMSB(), entityIdProto.getEntityIdLSB()));
+ }
+
private static boolean isNotNull(Object obj) {
return obj != null;
}
diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto
index 8372753df4..a05fdd5d36 100644
--- a/common/proto/src/main/proto/queue.proto
+++ b/common/proto/src/main/proto/queue.proto
@@ -82,6 +82,12 @@ enum ApiUsageRecordKeyProto {
INACTIVE_DEVICES = 10;
}
+message EntityIdProto {
+ int64 entityIdMSB = 1;
+ int64 entityIdLSB = 2;
+ EntityTypeProto type = 4;
+}
+
/**
* Service Discovery Data Structures;
*/
@@ -896,11 +902,25 @@ message TsRollingArgumentProto {
repeated TsDoubleValProto tsValue = 4;
}
+message GeofencingZoneProto {
+ EntityIdProto zoneId = 1;
+ int64 ts = 2;
+ string perimeterDefinition = 3;
+ int64 version = 4;
+ optional bool inside = 5;
+}
+
+message GeofencingArgumentProto {
+ string argName = 1;
+ repeated GeofencingZoneProto zones = 2;
+}
+
message CalculatedFieldStateProto {
CalculatedFieldEntityCtxIdProto id = 1;
string type = 2;
repeated SingleValueArgumentProto singleValueArguments = 3;
repeated TsRollingArgumentProto rollingValueArguments = 4;
+ repeated GeofencingArgumentProto geofencingArguments = 5;
}
//Used to report session state to tb-Service and persist this state in the cache on the tb-Service level.
diff --git a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java
index a46e489268..0f4733cafb 100644
--- a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java
+++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java
@@ -22,6 +22,7 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.thingsboard.common.util.JacksonUtil;
@@ -43,6 +44,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKey;
@@ -344,4 +346,24 @@ class ProtoUtilsTest {
assertThat(toDeviceRpcRequestActorMsg.getMsg()).isEqualTo(request);
}
+ @ParameterizedTest
+ @EnumSource(EntityType.class)
+ void testEntityIdProto_toProto_fromProto(EntityType entityType) {
+ UUID uuid = UUID.fromString("51a514d7-ea8f-496d-b567-f6e76f0f9b83");
+
+ EntityId original = EntityIdFactory.getByTypeAndUuid(entityType, uuid);
+ assertThat(original).isNotNull();
+
+ // toProto
+ TransportProtos.EntityIdProto proto = ProtoUtils.toProto(original);
+ assertThat(proto).isNotNull();
+ assertThat(proto.getType().getNumber()).isEqualTo(entityType.getProtoNumber());
+ assertThat(proto.getEntityIdMSB()).isEqualTo(uuid.getMostSignificantBits());
+ assertThat(proto.getEntityIdLSB()).isEqualTo(uuid.getLeastSignificantBits());
+
+ // fromProto
+ EntityId restored = ProtoUtils.fromProto(proto);
+ assertThat(restored).isNotNull().isEqualTo(original);
+ }
+
}
diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java
index f95b08195e..73a2183564 100644
--- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java
+++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java
@@ -26,7 +26,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
)
@JsonSubTypes({
@JsonSubTypes.Type(value = TbelCfSingleValueArg.class, name = "SINGLE_VALUE"),
- @JsonSubTypes.Type(value = TbelCfTsRollingArg.class, name = "TS_ROLLING")
+ @JsonSubTypes.Type(value = TbelCfTsRollingArg.class, name = "TS_ROLLING"),
+ @JsonSubTypes.Type(value = TbelCfTsGeofencingArg.class, name = "GEOFENCING_CF_ARGUMENT_VALUE"),
})
public interface TbelCfArg extends TbelCfObject {
diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java
new file mode 100644
index 0000000000..f1e8ec16db
--- /dev/null
+++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java
@@ -0,0 +1,43 @@
+/**
+ * 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.tbel;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class TbelCfTsGeofencingArg implements TbelCfArg {
+
+ private final Object value;
+
+ @JsonCreator
+ public TbelCfTsGeofencingArg(@JsonProperty("value") Object value) {
+ this.value = value;
+ }
+
+ @Override
+ public String getType() {
+ return "GEOFENCING_CF_ARGUMENT_VALUE";
+ }
+
+
+ @Override
+ public long memorySize() {
+ return OBJ_SIZE;
+ }
+
+}
diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java
index 91e7dedbf2..fee2f04e24 100644
--- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java
+++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java
@@ -80,6 +80,8 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
@@ -405,18 +407,34 @@ public abstract class AbstractGatewaySessionHandler