Browse Source

Merge with master

pull/10210/head
Andrii Shvaika 2 years ago
parent
commit
decbbcb9cd
  1. 3
      application/src/main/data/json/system/widget_bundles/buttons.json
  2. 3
      application/src/main/data/json/system/widget_bundles/control_widgets.json
  3. 36
      application/src/main/data/json/system/widget_types/power_button.json
  4. 3
      application/src/main/data/json/system/widget_types/progress_bar.json
  5. 3
      application/src/main/data/json/system/widget_types/simple_value_and_chart_card.json
  6. 39
      application/src/main/data/json/system/widget_types/slider.json
  7. 36
      application/src/main/data/json/tenant/dashboards/gateways.json
  8. 13
      application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java
  9. 2
      application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java
  10. 2
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
  11. 5
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java
  12. 1
      application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java
  13. 2
      application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java
  14. 28
      application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java
  15. 2
      application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java
  16. 8
      application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java
  17. 38
      application/src/main/java/org/thingsboard/server/service/ws/notification/sub/AbstractNotificationSubscription.java
  18. 6
      application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsCountSubscription.java
  19. 3
      application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsSubscription.java
  20. 37
      application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java
  21. 23
      application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java
  22. 7
      application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java
  23. 87
      application/src/test/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructorTest.java
  24. 4
      application/src/test/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessorTest.java
  25. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/edge/EdgeService.java
  26. 15
      common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DefaultCoapClientContext.java
  27. 8
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java
  28. 24
      dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java
  29. 4
      dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java
  30. 5
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java
  31. 3
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java
  32. 67
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java
  33. 3
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNodeConfiguration.java
  34. 4
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoader.java
  35. 23
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/GpsGeofencingEvents.java
  36. 39
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/GpsGeofencingActionTestCase.java
  37. 259
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNodeTest.java
  38. 18
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoaderTest.java
  39. 1
      ui-ngx/package.json
  40. 1
      ui-ngx/src/app/core/api/widget-api.models.ts
  41. 37
      ui-ngx/src/app/core/http/image.service.ts
  42. 1
      ui-ngx/src/app/core/notification/notification.models.ts
  43. 8
      ui-ngx/src/app/core/services/item-buffer.service.ts
  44. 30
      ui-ngx/src/app/modules/common/modules-map.ts
  45. 8
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts
  46. 2
      ui-ngx/src/app/modules/home/components/filter/filters-edit.component.ts
  47. 2
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html
  48. 15
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.scss
  49. 6
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html
  50. 10
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.scss
  51. 6
      ui-ngx/src/app/modules/home/components/public-api.ts
  52. 4
      ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html
  53. 5
      ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts
  54. 6
      ui-ngx/src/app/modules/home/components/vc/version-control.component.html
  55. 16
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  56. 197
      ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html
  57. 195
      ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts
  58. 4
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.html
  59. 50
      ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.html
  60. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.ts
  61. 4
      ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html
  62. 271
      ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html
  63. 309
      ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.ts
  64. 17
      ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts
  65. 31
      ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.scss
  66. 6
      ui-ngx/src/app/modules/home/components/widget/lib/button/action-button-widget.component.html
  67. 6
      ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.html
  68. 2
      ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts
  69. 8
      ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.ts
  70. 2
      ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html
  71. 7
      ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.scss
  72. 3
      ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts
  73. 63
      ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts
  74. 31
      ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.models.ts
  75. 23
      ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts
  76. 3
      ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts
  77. 26
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.html
  78. 64
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.scss
  79. 203
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.ts
  80. 953
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts
  81. 6
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html
  82. 59
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.html
  83. 135
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.scss
  84. 347
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.ts
  85. 196
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.models.ts
  86. 142
      ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.html
  87. 88
      ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.ts
  88. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/progress-bar-widget-settings.component.html
  89. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.html
  90. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.ts
  91. 8
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings.component.ts
  92. 19
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html
  93. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts
  94. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.html
  95. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html
  96. 216
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.html
  97. 186
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.ts
  98. 46
      ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.html
  99. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts
  100. 18
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts

3
application/src/main/data/json/system/widget_bundles/buttons.json

@ -9,6 +9,7 @@
},
"widgetTypeFqns": [
"action_button",
"command_button"
"command_button",
"power_button"
]
}

3
application/src/main/data/json/system/widget_bundles/control_widgets.json

@ -9,6 +9,9 @@
},
"widgetTypeFqns": [
"single_switch",
"command_button",
"power_button",
"slider",
"control_widgets.switch_control",
"control_widgets.slide_toggle_control",
"control_widgets.round_switch",

36
application/src/main/data/json/system/widget_types/power_button.json

File diff suppressed because one or more lines are too long

3
application/src/main/data/json/system/widget_types/progress_bar.json

File diff suppressed because one or more lines are too long

3
application/src/main/data/json/system/widget_types/simple_value_and_chart_card.json

File diff suppressed because one or more lines are too long

39
application/src/main/data/json/system/widget_types/slider.json

File diff suppressed because one or more lines are too long

36
application/src/main/data/json/tenant/dashboards/gateways.json

@ -2317,7 +2317,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "45e4507d-3adc-bb31-8b2b-1ba09bbd56ac"
@ -2484,7 +2484,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "852eccce-98eb-24db-c783-bdd62566f906"
@ -2650,7 +2650,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "3c31ba62-e760-2bea-4c8d-d32784a86c24"
@ -2816,7 +2816,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "4b55ea81-93bf-4206-9166-3e0bdc1dd9f3"
@ -2982,7 +2982,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "babf88d0-a118-e2b5-f10e-3a5970c8a65b"
@ -3148,7 +3148,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "94de7690-f91d-b032-6771-85af99abd749"
@ -3314,7 +3314,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "18414f44-1c65-536a-14de-eaf21a7d56bd"
@ -3480,7 +3480,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "794974da-c9d2-a9f7-be47-c9eb642094e8"
@ -3646,7 +3646,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "2add705b-3e53-8559-8126-380cac686fb0"
@ -3812,7 +3812,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "7e1ba820-9992-d52a-579b-20485abb3926"
@ -3978,7 +3978,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "91af27c1-b37c-2276-6022-a332e41b2b33"
@ -4144,7 +4144,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "26cf8696-054b-13ec-7984-6fc5df20e6f1"
@ -4310,7 +4310,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "1dcfaf24-32be-cd19-62d6-86d12cc6a7ef"
@ -4476,7 +4476,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "ad2bc817-f3c4-150c-4672-8fe0c38aee8d"
@ -4642,7 +4642,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "d1ad84cd-bd9c-4dca-e4a0-f444ae8598bd"
@ -4808,7 +4808,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "bf80eef9-b879-9a08-40a4-488dbdefa125"
@ -4974,7 +4974,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "b5a406b3-cc0a-8a09-9aec-3f8befae5fb8"
@ -5140,7 +5140,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "ec1dfba3-4b43-2491-8948-f602337f8a3b"

13
application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java

@ -92,7 +92,9 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToTransportUpdateCredentialsProto;
import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg;
import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto;
import org.thingsboard.server.gen.transport.TransportProtos.UplinkNotificationMsg;
import org.thingsboard.server.service.rpc.RpcSubmitStrategy;
import org.thingsboard.server.service.state.DefaultDeviceStateService;
import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper;
import jakarta.annotation.Nullable;
@ -175,7 +177,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
private EdgeId findRelatedEdgeId() {
List<EntityRelation> result =
systemContext.getRelationService().findByToAndType(tenantId, deviceId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON);
systemContext.getRelationService().findByToAndType(tenantId, deviceId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.EDGE);
if (result != null && result.size() > 0) {
EntityRelation relationToEdge = result.get(0);
if (relationToEdge.getFrom() != null && relationToEdge.getFrom().getId() != null) {
@ -214,8 +216,11 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
if (systemContext.isEdgesEnabled() && edgeId != null) {
log.debug("[{}][{}] device is related to edge: [{}]. Saving RPC request: [{}][{}] to edge queue", tenantId, deviceId, edgeId.getId(), rpcId, requestId);
try {
saveRpcRequestToEdgeQueue(request, requestId).get();
sent = true;
if (systemContext.getEdgeService().isEdgeActiveAsync(tenantId, edgeId, DefaultDeviceStateService.ACTIVITY_STATE).get()) {
saveRpcRequestToEdgeQueue(request, requestId).get();
} else {
log.error("[{}][{}][{}] Failed to save RPC request to edge queue {}. The Edge is currently offline or unreachable", tenantId, deviceId, edgeId.getId(), request);
}
} catch (InterruptedException | ExecutionException e) {
log.error("[{}][{}][{}] Failed to save RPC request to edge queue {}", tenantId, deviceId, edgeId.getId(), request, e);
}
@ -472,7 +477,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
callback.onSuccess();
}
private void processUplinkNotificationMsg(SessionInfoProto sessionInfo, TransportProtos.UplinkNotificationMsg uplinkNotificationMsg) {
private void processUplinkNotificationMsg(SessionInfoProto sessionInfo, UplinkNotificationMsg uplinkNotificationMsg) {
String nodeId = sessionInfo.getNodeId();
sessions.entrySet().stream()
.filter(kv -> kv.getValue().getSessionInfo().getNodeId().equals(nodeId) && (kv.getValue().isSubscribedToAttributes() || kv.getValue().isSubscribedToRPC()))

2
application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java

@ -16,7 +16,6 @@
package org.thingsboard.server.actors.ruleChain;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.actors.TbActorCtx;
import org.thingsboard.server.actors.TbActorRef;
@ -29,6 +28,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleState;
import org.thingsboard.server.common.data.relation.EntityRelation;

2
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java

@ -478,6 +478,8 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
TbMsgMetaData md = new TbMsgMetaData();
if (!persistToTelemetry) {
md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE);
md.putValue("edgeName", edge.getName());
md.putValue("edgeType", edge.getType());
}
TbMsg tbMsg = TbMsg.newMsg(msgType, edgeId, md, TbMsgDataType.JSON, data);
clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null);

5
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java

@ -18,6 +18,7 @@ package org.thingsboard.server.service.edge.rpc.processor.rule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.rule.RuleChain;
@ -53,6 +54,10 @@ public class RuleChainEdgeProcessor extends BaseEdgeProcessor {
isRoot = Boolean.parseBoolean(edgeEvent.getBody().get(EDGE_IS_ROOT_BODY_KEY).asText());
} catch (Exception ignored) {}
}
if (!isRoot) {
Edge edge = edgeService.findEdgeById(edgeEvent.getTenantId(), edgeEvent.getEdgeId());
isRoot = edge.getRootRuleChainId().equals(ruleChainId);
}
UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction());
RuleChainUpdateMsg ruleChainUpdateMsg = ((RuleChainMsgConstructor)
ruleChainMsgConstructorFactory.getMsgConstructorByEdgeVersion(edgeVersion))

1
application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java

@ -24,7 +24,6 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.queue.RuleEngineException;
import org.thingsboard.server.common.msg.queue.RuleNodeInfo;
import org.thingsboard.server.common.msg.queue.TbMsgCallback;
import org.thingsboard.server.common.msg.tools.TbRateLimitsException;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

2
application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java

@ -36,8 +36,6 @@ public abstract class TbSubscription<T> {
private final TbSubscriptionType type;
private final BiConsumer<TbSubscription<T>, T> updateProcessor;
protected final AtomicInteger sequence = new AtomicInteger();
@Override
public boolean equals(Object o) {
if (this == o) return true;

28
application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java

@ -259,13 +259,13 @@ public class DefaultWebSocketService implements WebSocketService {
}
@Override
public void sendUpdate(String sessionId, TelemetrySubscriptionUpdate update) {
sendUpdate(sessionId, update.getSubscriptionId(), update);
public void sendUpdate(String sessionId, int cmdId, TelemetrySubscriptionUpdate update) {
doSendUpdate(sessionId, cmdId, update);
}
@Override
public void sendUpdate(String sessionId, CmdUpdate update) {
sendUpdate(sessionId, update.getCmdId(), update);
doSendUpdate(sessionId, update.getCmdId(), update);
}
@Override
@ -274,7 +274,7 @@ public class DefaultWebSocketService implements WebSocketService {
sendUpdate(sessionRef, update);
}
private <T> void sendUpdate(String sessionId, int cmdId, T update) {
private <T> void doSendUpdate(String sessionId, int cmdId, T update) {
WsSessionMetaData md = wsSessionsMap.get(sessionId);
if (md != null) {
sendUpdate(md.getSessionRef(), cmdId, update);
@ -288,7 +288,7 @@ public class DefaultWebSocketService implements WebSocketService {
try {
msgEndpoint.close(md.getSessionRef(), status);
} catch (IOException e) {
log.warn("[{}] Failed to send session close: {}", sessionId, e);
log.warn("[{}] Failed to send session close", sessionId, e);
}
}
}
@ -439,7 +439,7 @@ public class DefaultWebSocketService implements WebSocketService {
TbAttributeSubscription sub = TbAttributeSubscription.builder()
.serviceId(serviceId)
.sessionId(sessionId)
.subscriptionId(cmd.getCmdId())
.subscriptionId(sessionRef.getSessionSubIdSeq().incrementAndGet())
.tenantId(sessionRef.getSecurityCtx().getTenantId())
.entityId(entityId)
.queryTs(queryTs)
@ -449,7 +449,7 @@ public class DefaultWebSocketService implements WebSocketService {
.updateProcessor((subscription, update) -> {
subLock.lock();
try {
sendUpdate(subscription.getSessionId(), update);
sendUpdate(subscription.getSessionId(), cmd.getCmdId(), update);
} finally {
subLock.unlock();
}
@ -545,7 +545,7 @@ public class DefaultWebSocketService implements WebSocketService {
TbAttributeSubscription sub = TbAttributeSubscription.builder()
.serviceId(serviceId)
.sessionId(sessionId)
.subscriptionId(cmd.getCmdId())
.subscriptionId(sessionRef.getSessionSubIdSeq().incrementAndGet())
.tenantId(sessionRef.getSecurityCtx().getTenantId())
.entityId(entityId)
.queryTs(queryTs)
@ -554,7 +554,7 @@ public class DefaultWebSocketService implements WebSocketService {
.updateProcessor((subscription, update) -> {
subLock.lock();
try {
sendUpdate(subscription.getSessionId(), update);
sendUpdate(subscription.getSessionId(), cmd.getCmdId(), update);
} finally {
subLock.unlock();
}
@ -643,13 +643,13 @@ public class DefaultWebSocketService implements WebSocketService {
TbTimeSeriesSubscription sub = TbTimeSeriesSubscription.builder()
.serviceId(serviceId)
.sessionId(sessionId)
.subscriptionId(cmd.getCmdId())
.subscriptionId(sessionRef.getSessionSubIdSeq().incrementAndGet())
.tenantId(sessionRef.getSecurityCtx().getTenantId())
.entityId(entityId)
.updateProcessor((subscription, update) -> {
subLock.lock();
try {
sendUpdate(subscription.getSessionId(), update);
sendUpdate(subscription.getSessionId(), cmd.getCmdId(), update);
} finally {
subLock.unlock();
}
@ -698,13 +698,13 @@ public class DefaultWebSocketService implements WebSocketService {
TbTimeSeriesSubscription sub = TbTimeSeriesSubscription.builder()
.serviceId(serviceId)
.sessionId(sessionId)
.subscriptionId(cmd.getCmdId())
.subscriptionId(sessionRef.getSessionSubIdSeq().incrementAndGet())
.tenantId(sessionRef.getSecurityCtx().getTenantId())
.entityId(entityId)
.updateProcessor((subscription, update) -> {
subLock.lock();
try {
sendUpdate(subscription.getSessionId(), update);
sendUpdate(subscription.getSessionId(), cmd.getCmdId(), update);
} finally {
subLock.unlock();
}
@ -836,7 +836,7 @@ public class DefaultWebSocketService implements WebSocketService {
try {
msgEndpoint.sendPing(md.getSessionRef(), currentTime);
} catch (IOException e) {
log.warn("[{}] Failed to send ping: {}", md.getSessionRef().getSessionId(), e);
log.warn("[{}] Failed to send ping:", md.getSessionRef().getSessionId(), e);
}
}));
}

2
application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java

@ -29,7 +29,7 @@ public interface WebSocketService {
void handleCommands(WebSocketSessionRef sessionRef, WsCommandsWrapper commandsWrapper);
void sendUpdate(String sessionId, TelemetrySubscriptionUpdate update);
void sendUpdate(String sessionId, int cmdId, TelemetrySubscriptionUpdate update);
void sendUpdate(String sessionId, CmdUpdate update);

8
application/src/main/java/org/thingsboard/server/service/ws/notification/DefaultNotificationCommandsHandler.java

@ -115,7 +115,7 @@ public class DefaultNotificationCommandsHandler implements NotificationCommandsH
private void fetchUnreadNotificationsCount(NotificationsCountSubscription subscription) {
log.trace("[{}, subId: {}] Fetching unread notifications count from DB", subscription.getSessionId(), subscription.getSubscriptionId());
int unreadCount = notificationService.countUnreadNotificationsByRecipientId(subscription.getTenantId(), (UserId) subscription.getEntityId());
subscription.getUnreadCounter().set(unreadCount);
subscription.getTotalUnreadCounter().set(unreadCount);
}
@ -196,20 +196,20 @@ public class DefaultNotificationCommandsHandler implements NotificationCommandsH
private void handleNotificationUpdate(NotificationsCountSubscription subscription, NotificationUpdate update) {
log.trace("[{}, subId: {}] Handling notification update for count sub: {}", subscription.getSessionId(), subscription.getSubscriptionId(), update);
if (update.isCreated()) {
subscription.getUnreadCounter().incrementAndGet();
subscription.getTotalUnreadCounter().incrementAndGet();
sendUpdate(subscription.getSessionId(), subscription.createUpdate());
} else if (update.isUpdated()) {
if (update.getNewStatus() == NotificationStatus.READ) {
if (update.isAllNotifications()) {
fetchUnreadNotificationsCount(subscription);
} else {
subscription.getUnreadCounter().decrementAndGet();
subscription.getTotalUnreadCounter().decrementAndGet();
}
sendUpdate(subscription.getSessionId(), subscription.createUpdate());
}
} else if (update.isDeleted()) {
if (update.getNotification().getStatus() != NotificationStatus.READ) {
subscription.getUnreadCounter().decrementAndGet();
subscription.getTotalUnreadCounter().decrementAndGet();
sendUpdate(subscription.getSessionId(), subscription.createUpdate());
}
}

38
application/src/main/java/org/thingsboard/server/service/ws/notification/sub/AbstractNotificationSubscription.java

@ -0,0 +1,38 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.ws.notification.sub;
import lombok.Getter;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.service.subscription.TbSubscription;
import org.thingsboard.server.service.subscription.TbSubscriptionType;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
@Getter
public abstract class AbstractNotificationSubscription<T> extends TbSubscription<T> {
protected final AtomicInteger sequence = new AtomicInteger();
protected final AtomicInteger totalUnreadCounter = new AtomicInteger();
public AbstractNotificationSubscription(String serviceId, String sessionId, int subscriptionId, TenantId tenantId, EntityId entityId, TbSubscriptionType type, BiConsumer<TbSubscription<T>, T> updateProcessor) {
super(serviceId, sessionId, subscriptionId, tenantId, entityId, type, updateProcessor);
}
}

6
application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsCountSubscription.java

@ -27,9 +27,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
@Getter
public class NotificationsCountSubscription extends TbSubscription<NotificationsSubscriptionUpdate> {
private final AtomicInteger unreadCounter = new AtomicInteger();
public class NotificationsCountSubscription extends AbstractNotificationSubscription<NotificationsSubscriptionUpdate> {
@Builder
public NotificationsCountSubscription(String serviceId, String sessionId, int subscriptionId, TenantId tenantId, EntityId entityId,
@ -40,7 +38,7 @@ public class NotificationsCountSubscription extends TbSubscription<Notifications
public UnreadNotificationsCountUpdate createUpdate() {
return UnreadNotificationsCountUpdate.builder()
.cmdId(getSubscriptionId())
.totalUnreadCount(unreadCounter.get())
.totalUnreadCount(totalUnreadCounter.get())
.sequenceNumber(sequence.incrementAndGet())
.build();
}

3
application/src/main/java/org/thingsboard/server/service/ws/notification/sub/NotificationsSubscription.java

@ -35,11 +35,10 @@ import java.util.function.BiConsumer;
import java.util.stream.Collectors;
@Getter
public class NotificationsSubscription extends TbSubscription<NotificationsSubscriptionUpdate> {
public class NotificationsSubscription extends AbstractNotificationSubscription<NotificationsSubscriptionUpdate> {
private final Map<UUID, Notification> latestUnreadNotifications = new HashMap<>();
private final int limit;
private final AtomicInteger totalUnreadCounter = new AtomicInteger();
@Builder
public NotificationsSubscription(String serviceId, String sessionId, int subscriptionId, TenantId tenantId, EntityId entityId,

37
application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java

@ -710,7 +710,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest {
edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build());
Assert.assertTrue(edgeImitator.waitForResponses());
Assert.assertTrue(onUpdateCallback.getSubscribeLatch().await(30, TimeUnit.SECONDS));
Assert.assertTrue(onUpdateCallback.getSubscribeLatch().await(TIMEOUT, TimeUnit.SECONDS));
Assert.assertEquals(JacksonUtil.newObjectNode().put(attrKey, attrValue),
JacksonUtil.fromBytes(onUpdateCallback.getPayloadBytes()));
@ -797,7 +797,21 @@ public class DeviceEdgeTest extends AbstractEdgeTest {
// clean up stored edge events
edgeEventService.cleanupEvents(1);
// perform rpc call to verify edgeId in DeviceActorMessageProcessor updated properly
// edge is disconnected: perform rpc call - no edge event saved
doPostAsync(
"/api/rpc/oneway/" + device.getId().getId().toString(),
JacksonUtil.toString(createDefaultRpc()),
String.class,
status().isOk());
Awaitility.await()
.atMost(TIMEOUT, TimeUnit.SECONDS)
.until(() -> {
PageData<EdgeEvent> result = edgeEventService.findEdgeEvents(tenantId, tmpEdge.getId(), 0L, null, new TimePageLink(1));
return result.getTotalElements() == 0;
});
// edge is connected: perform rpc call to verify edgeId in DeviceActorMessageProcessor updated properly
simulateEdgeActivation(tmpEdge);
doPostAsync(
"/api/rpc/oneway/" + device.getId().getId().toString(),
JacksonUtil.toString(createDefaultRpc()),
@ -856,4 +870,23 @@ public class DeviceEdgeTest extends AbstractEdgeTest {
return rpc;
}
private void simulateEdgeActivation(Edge edge) throws Exception {
ObjectNode attributes = JacksonUtil.newObjectNode();
attributes.put("active", true);
doPost("/api/plugins/telemetry/EDGE/" + edge.getId() + "/attributes/" + DataConstants.SERVER_SCOPE, attributes);
Awaitility.await()
.atMost(TIMEOUT, TimeUnit.SECONDS)
.until(() -> {
List<Map<String, Object>> values = doGetAsyncTyped("/api/plugins/telemetry/EDGE/" + edge.getId() +
"/values/attributes/SERVER_SCOPE", new TypeReference<>() {});
Optional<Map<String, Object>> activeAttrOpt = values.stream().filter(att -> att.get("key").equals("active")).findFirst();
if (activeAttrOpt.isEmpty()) {
return false;
}
Map<String, Object> activeAttr = activeAttrOpt.get();
return "true".equals(activeAttr.get("value").toString());
});
}
}

23
application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java

@ -146,7 +146,7 @@ public class RuleChainEdgeTest extends AbstractEdgeTest {
}
}
private void createRuleChainMetadata(RuleChain ruleChain) {
private RuleChainMetaData createRuleChainMetadata(RuleChain ruleChain) {
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
@ -182,7 +182,7 @@ public class RuleChainEdgeTest extends AbstractEdgeTest {
ruleChainMetaData.addConnectionInfo(0, 2, "fail");
ruleChainMetaData.addConnectionInfo(1, 2, "success");
doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
return doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
}
@Test
@ -193,9 +193,10 @@ public class RuleChainEdgeTest extends AbstractEdgeTest {
ruleChain.setType(RuleChainType.EDGE);
RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class);
edgeImitator.expectMessageAmount(1);
edgeImitator.expectMessageAmount(2);
doPost("/api/edge/" + edge.getUuidId()
+ "/ruleChain/" + savedRuleChain.getUuidId(), RuleChain.class);
RuleChainMetaData metaData = createRuleChainMetadata(savedRuleChain);
Assert.assertTrue(edgeImitator.waitForMessages());
// set new rule chain as root
@ -213,6 +214,22 @@ public class RuleChainEdgeTest extends AbstractEdgeTest {
Assert.assertTrue(ruleChainMsg.isRoot());
Assert.assertEquals(savedRuleChain.getId(), ruleChainMsg.getId());
// update metadata for root rule chain
edgeImitator.expectMessageAmount(1);
metaData.getNodes().forEach(n -> n.setDebugMode(true));
doPost("/api/ruleChain/metadata", metaData, RuleChainMetaData.class);
Assert.assertTrue(edgeImitator.waitForMessages());
ruleChainUpdateMsgOpt = edgeImitator.findMessageByType(RuleChainUpdateMsg.class);
Assert.assertTrue(ruleChainUpdateMsgOpt.isPresent());
ruleChainUpdateMsg = ruleChainUpdateMsgOpt.get();
ruleChainMsg = JacksonUtil.fromString(ruleChainUpdateMsg.getEntity(), RuleChain.class, true);
Assert.assertNotNull(ruleChainMsg);
Assert.assertTrue(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE.equals(ruleChainUpdateMsg.getMsgType()) ||
UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE.equals(ruleChainUpdateMsg.getMsgType()));
Assert.assertEquals(savedRuleChain.getId(), ruleChainMsg.getId());
Assert.assertEquals(savedRuleChain.getName(), ruleChainMsg.getName());
Assert.assertTrue(ruleChainMsg.isRoot());
// revert root rule chain
edgeImitator.expectMessageAmount(1);
doPost("/api/edge/" + edge.getUuidId()

7
application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java

@ -26,6 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
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.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
@ -72,8 +73,6 @@ import java.util.stream.Collectors;
@Slf4j
public class EdgeImitator {
public static final int TIMEOUT_IN_SECONDS = 30;
private String routingKey;
private String routingSecret;
@ -344,7 +343,7 @@ public class EdgeImitator {
}
public boolean waitForMessages() throws InterruptedException {
return waitForMessages(TIMEOUT_IN_SECONDS);
return waitForMessages(AbstractWebTest.TIMEOUT);
}
public boolean waitForMessages(int timeoutInSeconds) throws InterruptedException {
@ -359,7 +358,7 @@ public class EdgeImitator {
}
public boolean waitForResponses() throws InterruptedException {
return responsesLatch.await(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS);
return responsesLatch.await(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS);
}
public void expectResponsesAmount(int messageAmount) {

87
application/src/test/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructorTest.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.service.edge.rpc.constructor;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.junit.Assert;
@ -61,7 +60,7 @@ public class RuleChainMsgConstructorTest {
}
@Test
public void testConstructRuleChainMetadataUpdatedMsg_V_3_4_0() throws JsonProcessingException {
public void testConstructRuleChainMetadataUpdatedMsg_V_3_4_0() {
RuleChainId ruleChainId = new RuleChainId(UUID.randomUUID());
RuleChainMetaData ruleChainMetaData = createRuleChainMetaData(
ruleChainId, 3, createRuleNodes(ruleChainId), createConnections());
@ -80,7 +79,7 @@ public class RuleChainMsgConstructorTest {
}
@Test
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_3() throws JsonProcessingException {
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_3() {
RuleChainId ruleChainId = new RuleChainId(UUID.randomUUID());
RuleChainMetaData ruleChainMetaData = createRuleChainMetaData(
ruleChainId, 3, createRuleNodes(ruleChainId), createConnections());
@ -120,7 +119,7 @@ public class RuleChainMsgConstructorTest {
}
@Test
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_0() throws JsonProcessingException {
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_0() {
RuleChainId ruleChainId = new RuleChainId(UUID.randomUUID());
RuleChainMetaData ruleChainMetaData = createRuleChainMetaData(ruleChainId, 3, createRuleNodes(ruleChainId), createConnections());
RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg =
@ -161,7 +160,7 @@ public class RuleChainMsgConstructorTest {
}
@Test
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_0_inDifferentOrder() throws JsonProcessingException {
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_0_inDifferentOrder() {
// same rule chain metadata, but different order of rule nodes
RuleChainId ruleChainId = new RuleChainId(UUID.randomUUID());
RuleChainMetaData ruleChainMetaData1 = createRuleChainMetaData(ruleChainId, 8, createRuleNodesInDifferentOrder(ruleChainId), createConnectionsInDifferentOrder());
@ -254,7 +253,7 @@ public class RuleChainMsgConstructorTest {
return result;
}
private List<RuleNode> createRuleNodes(RuleChainId ruleChainId) throws JsonProcessingException {
private List<RuleNode> createRuleNodes(RuleChainId ruleChainId) {
List<RuleNode> result = new ArrayList<>();
result.add(getOutputNode(ruleChainId));
result.add(getAcknowledgeNode(ruleChainId));
@ -301,7 +300,7 @@ public class RuleChainMsgConstructorTest {
return result;
}
private List<RuleNode> createRuleNodesInDifferentOrder(RuleChainId ruleChainId) throws JsonProcessingException {
private List<RuleNode> createRuleNodesInDifferentOrder(RuleChainId ruleChainId) {
List<RuleNode> result = new ArrayList<>();
result.add(getPushToAnalyticsNode(ruleChainId));
result.add(getPushToCloudNode(ruleChainId));
@ -319,99 +318,99 @@ public class RuleChainMsgConstructorTest {
}
private RuleNode getOutputNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getOutputNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.flow.TbRuleChainOutputNode",
"Output node",
JacksonUtil.OBJECT_MAPPER.readTree("{\"version\":0}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"\",\"layoutX\":178,\"layoutY\":592}"));
JacksonUtil.toJsonNode("{\"version\":0}"),
JacksonUtil.toJsonNode("{\"description\":\"\",\"layoutX\":178,\"layoutY\":592}"));
}
private RuleNode getCheckpointNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getCheckpointNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.flow.TbCheckpointNode",
"Checkpoint node",
JacksonUtil.OBJECT_MAPPER.readTree("{\"queueName\":\"HighPriority\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"\",\"layoutX\":178,\"layoutY\":647}"));
JacksonUtil.toJsonNode("{\"queueName\":\"HighPriority\"}"),
JacksonUtil.toJsonNode("{\"description\":\"\",\"layoutX\":178,\"layoutY\":647}"));
}
private RuleNode getSaveTimeSeriesNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getSaveTimeSeriesNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode",
"Save Timeseries",
JacksonUtil.OBJECT_MAPPER.readTree("{\"defaultTTL\":0}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":823,\"layoutY\":157}"));
JacksonUtil.toJsonNode("{\"defaultTTL\":0}"),
JacksonUtil.toJsonNode("{\"layoutX\":823,\"layoutY\":157}"));
}
private RuleNode getMessageTypeSwitchNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getMessageTypeSwitchNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode",
"Message Type Switch",
JacksonUtil.OBJECT_MAPPER.readTree("{\"version\":0}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":347,\"layoutY\":149}"));
JacksonUtil.toJsonNode("{\"version\":0}"),
JacksonUtil.toJsonNode("{\"layoutX\":347,\"layoutY\":149}"));
}
private RuleNode getLogOtherNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getLogOtherNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.action.TbLogNode",
"Log Other",
JacksonUtil.OBJECT_MAPPER.readTree("{\"jsScript\":\"return '\\\\nIncoming message:\\\\n' + JSON.stringify(msg) + '\\\\nIncoming metadata:\\\\n' + JSON.stringify(metadata);\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":824,\"layoutY\":378}"));
JacksonUtil.toJsonNode("{\"jsScript\":\"return '\\\\nIncoming message:\\\\n' + JSON.stringify(msg) + '\\\\nIncoming metadata:\\\\n' + JSON.stringify(metadata);\"}"),
JacksonUtil.toJsonNode("{\"layoutX\":824,\"layoutY\":378}"));
}
private RuleNode getPushToCloudNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getPushToCloudNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode",
"Push to cloud",
JacksonUtil.OBJECT_MAPPER.readTree("{\"scope\":\"SERVER_SCOPE\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":1129,\"layoutY\":52}"));
JacksonUtil.toJsonNode("{\"scope\":\"SERVER_SCOPE\"}"),
JacksonUtil.toJsonNode("{\"layoutX\":1129,\"layoutY\":52}"));
}
private RuleNode getAcknowledgeNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getAcknowledgeNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.flow.TbAckNode",
"Acknowledge node",
JacksonUtil.OBJECT_MAPPER.readTree("{\"version\":0}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"\",\"layoutX\":177,\"layoutY\":703}"));
JacksonUtil.toJsonNode("{\"version\":0}"),
JacksonUtil.toJsonNode("{\"description\":\"\",\"layoutX\":177,\"layoutY\":703}"));
}
private RuleNode getDeviceProfileNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getDeviceProfileNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.profile.TbDeviceProfileNode",
"Device Profile Node",
JacksonUtil.OBJECT_MAPPER.readTree("{\"persistAlarmRulesState\":false,\"fetchAlarmRulesStateOnStart\":false}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"Process incoming messages from devices with the alarm rules defined in the device profile. Dispatch all incoming messages with \\\"Success\\\" relation type.\",\"layoutX\":187,\"layoutY\":468}"));
JacksonUtil.toJsonNode("{\"persistAlarmRulesState\":false,\"fetchAlarmRulesStateOnStart\":false}"),
JacksonUtil.toJsonNode("{\"description\":\"Process incoming messages from devices with the alarm rules defined in the device profile. Dispatch all incoming messages with \\\"Success\\\" relation type.\",\"layoutX\":187,\"layoutY\":468}"));
}
private RuleNode getSaveClientAttributesNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getSaveClientAttributesNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode",
"Save Client Attributes",
JacksonUtil.OBJECT_MAPPER.readTree("{\"scope\":\"CLIENT_SCOPE\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":824,\"layoutY\":52}"));
JacksonUtil.toJsonNode("{\"scope\":\"CLIENT_SCOPE\"}"),
JacksonUtil.toJsonNode("{\"layoutX\":824,\"layoutY\":52}"));
}
private RuleNode getLogRpcFromDeviceNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getLogRpcFromDeviceNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.action.TbLogNode",
"Log RPC from Device",
JacksonUtil.OBJECT_MAPPER.readTree("{\"jsScript\":\"return '\\\\nIncoming message:\\\\n' + JSON.stringify(msg) + '\\\\nIncoming metadata:\\\\n' + JSON.stringify(metadata);\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":825,\"layoutY\":266}"));
JacksonUtil.toJsonNode("{\"jsScript\":\"return '\\\\nIncoming message:\\\\n' + JSON.stringify(msg) + '\\\\nIncoming metadata:\\\\n' + JSON.stringify(metadata);\"}"),
JacksonUtil.toJsonNode("{\"layoutX\":825,\"layoutY\":266}"));
}
private RuleNode getRpcCallRequestNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getRpcCallRequestNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode",
"RPC Call Request",
JacksonUtil.OBJECT_MAPPER.readTree("{\"timeoutInSeconds\":60}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":824,\"layoutY\":466}"));
JacksonUtil.toJsonNode("{\"timeoutInSeconds\":60}"),
JacksonUtil.toJsonNode("{\"layoutX\":824,\"layoutY\":466}"));
}
private RuleNode getPushToAnalyticsNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getPushToAnalyticsNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.flow.TbRuleChainInputNode",
"Push to Analytics",
JacksonUtil.OBJECT_MAPPER.readTree("{\"ruleChainId\":\"af588000-6c7c-11ec-bafd-c9a47a5c8d99\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"\",\"layoutX\":477,\"layoutY\":560}"));
JacksonUtil.toJsonNode("{\"ruleChainId\":\"af588000-6c7c-11ec-bafd-c9a47a5c8d99\"}"),
JacksonUtil.toJsonNode("{\"description\":\"\",\"layoutX\":477,\"layoutY\":560}"));
}
}
}

4
application/src/test/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessorTest.java

@ -55,6 +55,7 @@ import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
@ -208,6 +209,9 @@ public abstract class BaseEdgeProcessorTest {
@MockBean
protected AttributesService attributesService;
@MockBean
protected TimeseriesService timeseriesService;
@MockBean
protected TbClusterService tbClusterService;

2
common/dao-api/src/main/java/org/thingsboard/server/dao/edge/EdgeService.java

@ -91,4 +91,6 @@ public interface EdgeService extends EntityDaoService {
PageData<EdgeId> findRelatedEdgeIdsByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink);
String findMissingToRelatedRuleChains(TenantId tenantId, EdgeId edgeId, String tbRuleChainInputNodeClassName);
ListenableFuture<Boolean> isEdgeActiveAsync(TenantId tenantId, EdgeId edgeId, String activityState);
}

15
common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DefaultCoapClientContext.java

@ -222,7 +222,7 @@ public class DefaultCoapClientContext implements CoapClientContext {
private void onUplink(TbCoapClientState client, boolean notifyOtherServers, long uplinkTs) {
PowerMode powerMode = client.getPowerMode();
PowerSavingConfiguration profileSettings = null;
if (powerMode == null) {
if (powerMode == null && client.getProfileId() != null) {
var clientProfile = getProfile(client.getProfileId());
if (clientProfile.isPresent()) {
profileSettings = clientProfile.get().getClientSettings();
@ -736,7 +736,7 @@ public class DefaultCoapClientContext implements CoapClientContext {
private boolean isDownlinkAllowed(TbCoapClientState client) {
PowerMode powerMode = client.getPowerMode();
PowerSavingConfiguration profileSettings = null;
if (powerMode == null) {
if (powerMode == null && client.getProfileId() != null) {
var clientProfile = getProfile(client.getProfileId());
if (clientProfile.isPresent()) {
profileSettings = clientProfile.get().getClientSettings();
@ -785,11 +785,12 @@ public class DefaultCoapClientContext implements CoapClientContext {
private PowerMode getPowerMode(TbCoapClientState client) {
PowerMode powerMode = client.getPowerMode();
if (powerMode == null) {
Optional<CoapDeviceProfileTransportConfiguration> deviceProfile = getProfile(client.getProfileId());
if (deviceProfile.isPresent()) {
powerMode = deviceProfile.get().getClientSettings().getPowerMode();
} else {
powerMode = PowerMode.PSM;
powerMode = PowerMode.PSM;
if (client.getProfileId() != null) {
Optional<CoapDeviceProfileTransportConfiguration> deviceProfile = getProfile(client.getProfileId());
if (deviceProfile.isPresent()) {
powerMode = deviceProfile.get().getClientSettings().getPowerMode();
}
}
}
return powerMode;

8
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java

@ -411,7 +411,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
public boolean isDownlinkAllowed(LwM2mClient client) {
PowerMode powerMode = client.getPowerMode();
OtherConfiguration profileSettings = null;
if (powerMode == null) {
if (powerMode == null && client.getProfileId() != null) {
var clientProfile = getProfile(client.getProfileId());
profileSettings = clientProfile.getClientLwM2mSettings();
powerMode = profileSettings.getPowerMode();
@ -419,7 +419,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
powerMode = PowerMode.DRX;
}
}
if (PowerMode.DRX.equals(powerMode) || otaUpdateService.isOtaDownloading(client)) {
if (powerMode == null || PowerMode.DRX.equals(powerMode) || otaUpdateService.isOtaDownloading(client)) {
return true;
}
client.lock();
@ -460,7 +460,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
public void onUplink(LwM2mClient client) {
PowerMode powerMode = client.getPowerMode();
OtherConfiguration profileSettings = null;
if (powerMode == null) {
if (powerMode == null && client.getProfileId() != null) {
var clientProfile = getProfile(client.getProfileId());
profileSettings = clientProfile.getClientLwM2mSettings();
powerMode = profileSettings.getPowerMode();
@ -468,7 +468,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext {
powerMode = PowerMode.DRX;
}
}
if (PowerMode.DRX.equals(powerMode)) {
if (powerMode == null || PowerMode.DRX.equals(powerMode)) {
client.updateLastUplinkTime();
return;
}

24
dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java

@ -30,6 +30,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
@ -47,6 +48,7 @@ import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageDataIterableByTenantIdEntityId;
import org.thingsboard.server.common.data.page.PageLink;
@ -54,6 +56,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.entity.AbstractCachedEntityService;
import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
@ -64,6 +67,7 @@ import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.service.Validator;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.user.UserService;
import jakarta.annotation.Nullable;
@ -103,12 +107,20 @@ public class EdgeServiceImpl extends AbstractCachedEntityService<EdgeCacheKey, E
@Autowired
private RelationService relationService;
@Autowired
private TimeseriesService timeseriesService;
@Autowired
private AttributesService attributesService;
@Autowired
private DataValidator<Edge> edgeValidator;
@Value("${edges.enabled}")
@Getter
private boolean edgesEnabled;
@Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry;
@TransactionalEventListener(classes = EdgeCacheEvictEvent.class)
@Override
@ -529,6 +541,18 @@ public class EdgeServiceImpl extends AbstractCachedEntityService<EdgeCacheKey, E
return result.toString();
}
@Override
public ListenableFuture<Boolean> isEdgeActiveAsync(TenantId tenantId, EdgeId edgeId, String key) {
ListenableFuture<? extends Optional<? extends KvEntry>> futureKvEntry;
if (persistToTelemetry) {
futureKvEntry = timeseriesService.findLatest(tenantId, edgeId, key);
} else {
futureKvEntry = attributesService.find(tenantId, edgeId, DataConstants.SERVER_SCOPE, key);
}
return Futures.transformAsync(futureKvEntry, kvEntryOpt ->
Futures.immediateFuture(kvEntryOpt.flatMap(KvEntry::getBooleanValue).orElse(false)), MoreExecutors.directExecutor());
}
private List<RuleChain> findEdgeRuleChains(TenantId tenantId, EdgeId edgeId) {
List<RuleChain> result = new ArrayList<>();
PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE);

4
dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java

@ -40,9 +40,9 @@ import org.thingsboard.server.common.data.notification.rule.trigger.config.Alarm
import org.thingsboard.server.common.data.notification.rule.trigger.config.ApiUsageLimitNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.DeviceActivityNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.DeviceActivityNotificationRuleTriggerConfig.DeviceEvent;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EdgeCommunicationFailureNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EdgeConnectionNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EdgeConnectionNotificationRuleTriggerConfig.EdgeConnectivityEvent;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EdgeCommunicationFailureNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EntitiesLimitNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EntityActionNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.NewPlatformVersionNotificationRuleTriggerConfig;
@ -347,7 +347,7 @@ public class DefaultNotifications {
public static final DefaultNotification edgeCommunicationFailures = DefaultNotification.builder()
.name("Edge communication failure notification")
.type(NotificationType.EDGE_COMMUNICATION_FAILURE)
.subject("Edge '${edgeName}' communication failure occured")
.subject("Edge '${edgeName}' communication failure occurred")
.text("Failure message: '${failureMsg}'")
.icon("error").color(RED_COLOR)
.button("Go to Edge").link("/edgeManagement/instances/${edgeId}")

5
rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

@ -525,12 +525,11 @@ public class RestClient implements Closeable {
}
public PageData<AlarmCommentInfo> getAlarmComments(AlarmId alarmId, PageLink pageLink) {
String urlSecondPart = "/api/alarm/{alarmId}/comment";
Map<String, String> params = new HashMap<>();
params.put("alarmId", alarmId.getId().toString());
addPageLinkToParam(params, pageLink);
return restTemplate.exchange(
baseURL + urlSecondPart + "&" + getUrlParams(pageLink),
baseURL + "/api/alarm/{alarmId}/comment?" + getUrlParams(pageLink),
HttpMethod.GET,
HttpEntity.EMPTY,
new ParameterizedTypeReference<PageData<AlarmCommentInfo>>() {

3
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java

@ -47,6 +47,7 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.INACTIVITY_EVENT;
import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST;
import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST;
import static org.thingsboard.server.common.data.msg.TbMsgType.TIMESERIES_UPDATED;
import static org.thingsboard.server.common.data.msg.TbMsgType.TO_SERVER_RPC_REQUEST;
@Slf4j
public abstract class AbstractTbMsgPushNode<T extends BaseTbMsgPushNodeConfiguration, S, U> implements TbNode {
@ -176,6 +177,6 @@ public abstract class AbstractTbMsgPushNode<T extends BaseTbMsgPushNodeConfigura
protected boolean isSupportedMsgType(TbMsg msg) {
return msg.isTypeOneOf(POST_TELEMETRY_REQUEST, POST_ATTRIBUTES_REQUEST, ATTRIBUTES_UPDATED, ATTRIBUTES_DELETED, TIMESERIES_UPDATED,
ALARM, CONNECT_EVENT, DISCONNECT_EVENT, ACTIVITY_EVENT, INACTIVITY_EVENT);
ALARM, CONNECT_EVENT, DISCONNECT_EVENT, ACTIVITY_EVENT, INACTIVITY_EVENT, TO_SERVER_RPC_REQUEST);
}
}

67
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java

@ -15,6 +15,8 @@
*/
package org.thingsboard.rule.engine.geo;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@ -29,6 +31,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg;
import java.util.Collections;
@ -40,6 +43,11 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.ENTERED;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.INSIDE;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.LEFT;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.OUTSIDE;
/**
* Created by ashvayka on 19.01.18.
*/
@ -47,15 +55,24 @@ import java.util.concurrent.TimeoutException;
@RuleNode(
type = ComponentType.ACTION,
name = "gps geofencing events",
version = 1,
configClazz = TbGpsGeofencingActionNodeConfiguration.class,
relationTypes = {"Success", "Entered", "Left", "Inside", "Outside"},
nodeDescription = "Produces incoming messages using GPS based geofencing",
nodeDetails = "Extracts latitude and longitude parameters from incoming message and returns different events based on configuration parameters",
nodeDetails = "Extracts latitude and longitude parameters from incoming message and returns different events based on configuration parameters. " +
"<br><br>" +
"If an object with coordinates extracted from incoming message enters the geofence, sends a message with the type <code>Entered</code>. " +
"If an object leaves the geofence, sends a message with the type <code>Left</code>. " +
"If the presence monitoring strategy <b>\"On first message\"</b> is selected, sends messages via rule node connection type <code>Inside</code> or <code>Outside</code> only the first time the geofencing and duration conditions are satisfied; otherwise sends messages via rule node connection type <code>Success</code>. " +
"If the presence monitoring strategy <b>\"On each message\"</b> is selected, sends messages via rule node connection type <code>Inside</code> or <code>Outside</code> every time the geofencing condition is satisfied. " +
"<br><br>" +
"Output connections: <code>Entered</code>, <code>Left</code>, <code>Inside</code>, <code>Outside</code>, <code>Success</code>",
uiResources = {"static/rulenode/rulenode-core-config.js"},
configDirective = "tbActionNodeGpsGeofencingConfig"
)
public class TbGpsGeofencingActionNode extends AbstractGeofencingNode<TbGpsGeofencingActionNodeConfiguration> {
private static final String REPORT_PRESENCE_STATUS_ON_EACH_MESSAGE = "reportPresenceStatusOnEachMessage";
private final Map<EntityId, EntityGeofencingState> entityStates = new HashMap<>();
private final Gson gson = new Gson();
private final JsonParser parser = new JsonParser();
@ -81,25 +98,32 @@ public class TbGpsGeofencingActionNode extends AbstractGeofencingNode<TbGpsGeofe
}
});
boolean told = false;
if (entityState.getStateSwitchTime() == 0L || entityState.isInside() != matches) {
switchState(ctx, msg.getOriginator(), entityState, matches, ts);
ctx.tellNext(msg, matches ? "Entered" : "Left");
told = true;
} else {
if (!entityState.isStayed()) {
long stayTime = ts - entityState.getStateSwitchTime();
if (stayTime > (entityState.isInside() ?
TimeUnit.valueOf(config.getMinInsideDurationTimeUnit()).toMillis(config.getMinInsideDuration()) : TimeUnit.valueOf(config.getMinOutsideDurationTimeUnit()).toMillis(config.getMinOutsideDuration()))) {
setStaid(ctx, msg.getOriginator(), entityState);
ctx.tellNext(msg, entityState.isInside() ? "Inside" : "Outside");
told = true;
}
}
ctx.tellNext(msg, matches ? ENTERED : LEFT);
return;
}
if (config.isReportPresenceStatusOnEachMessage()) {
ctx.tellNext(msg, entityState.isInside() ? INSIDE : OUTSIDE);
return;
}
if (!told) {
if (entityState.isStayed()) {
ctx.tellSuccess(msg);
return;
}
long stayTime = ts - entityState.getStateSwitchTime();
if (stayTime > (entityState.isInside() ?
TimeUnit.valueOf(config.getMinInsideDurationTimeUnit()).toMillis(config.getMinInsideDuration()) :
TimeUnit.valueOf(config.getMinOutsideDurationTimeUnit()).toMillis(config.getMinOutsideDuration()))) {
setStaid(ctx, msg.getOriginator(), entityState);
ctx.tellNext(msg, entityState.isInside() ? INSIDE : OUTSIDE);
return;
}
ctx.tellSuccess(msg);
}
private void switchState(TbContext ctx, EntityId entityId, EntityGeofencingState entityState, boolean matches, long ts) {
@ -128,4 +152,17 @@ public class TbGpsGeofencingActionNode extends AbstractGeofencingNode<TbGpsGeofe
protected Class<TbGpsGeofencingActionNodeConfiguration> getConfigClazz() {
return TbGpsGeofencingActionNodeConfiguration.class;
}
@Override
public TbPair<Boolean, JsonNode> upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException {
boolean hasChanges = false;
if (fromVersion == 0) {
if (!oldConfiguration.has(REPORT_PRESENCE_STATUS_ON_EACH_MESSAGE)) {
hasChanges = true;
((ObjectNode) oldConfiguration).put(REPORT_PRESENCE_STATUS_ON_EACH_MESSAGE, false);
}
}
return new TbPair<>(hasChanges, oldConfiguration);
}
}

3
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNodeConfiguration.java

@ -31,6 +31,8 @@ public class TbGpsGeofencingActionNodeConfiguration extends TbGpsGeofencingFilte
private String minInsideDurationTimeUnit;
private String minOutsideDurationTimeUnit;
private boolean reportPresenceStatusOnEachMessage;
@Override
public TbGpsGeofencingActionNodeConfiguration defaultConfiguration() {
TbGpsGeofencingActionNodeConfiguration configuration = new TbGpsGeofencingActionNodeConfiguration();
@ -43,6 +45,7 @@ public class TbGpsGeofencingActionNodeConfiguration extends TbGpsGeofencingFilte
configuration.setMinOutsideDurationTimeUnit(TimeUnit.MINUTES.name());
configuration.setMinInsideDuration(1);
configuration.setMinOutsideDuration(1);
configuration.setReportPresenceStatusOnEachMessage(true);
return configuration;
}
}

4
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoader.java

@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.id.AlarmId;
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.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityViewId;
import org.thingsboard.server.common.data.id.RuleChainId;
@ -63,6 +64,9 @@ public class EntitiesFieldsAsyncLoader {
case ENTITY_VIEW:
return toEntityFieldsDataAsync(ctx.getEntityViewService().findEntityViewByIdAsync(ctx.getTenantId(), (EntityViewId) originatorId),
EntityFieldsData::new, ctx);
case EDGE:
return toEntityFieldsDataAsync(ctx.getEdgeService().findEdgeByIdAsync(ctx.getTenantId(), (EdgeId) originatorId),
EntityFieldsData::new, ctx);
default:
return Futures.immediateFailedFuture(new TbNodeException("Unexpected originator EntityType: " + originatorId.getEntityType()));
}

23
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/GpsGeofencingEvents.java

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.util;
public class GpsGeofencingEvents {
public static final String ENTERED = "Entered";
public static final String INSIDE = "Inside";
public static final String LEFT = "Left";
public static final String OUTSIDE = "Outside";
}

39
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/GpsGeofencingActionTestCase.java

@ -0,0 +1,39 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.geo;
import lombok.Data;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.HashMap;
import java.util.Map;
@Data
public class GpsGeofencingActionTestCase {
private EntityId entityId;
private Map<EntityId, EntityGeofencingState> entityStates;
private boolean msgInside;
private boolean reportPresenceStatusOnEachMessage;
public GpsGeofencingActionTestCase(EntityId entityId, boolean msgInside, boolean reportPresenceStatusOnEachMessage, EntityGeofencingState entityGeofencingState) {
this.entityId = entityId;
this.msgInside = msgInside;
this.reportPresenceStatusOnEachMessage = reportPresenceStatusOnEachMessage;
this.entityStates = new HashMap<>();
this.entityStates.put(entityId, entityGeofencingState);
}
}

259
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNodeTest.java

@ -0,0 +1,259 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.geo;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.attributes.AttributesService;
import java.time.Duration;
import java.util.UUID;
import java.util.stream.Stream;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.ENTERED;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.INSIDE;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.LEFT;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.OUTSIDE;
import static org.thingsboard.server.common.data.msg.TbNodeConnectionType.SUCCESS;
@ExtendWith(MockitoExtension.class)
class TbGpsGeofencingActionNodeTest extends AbstractRuleNodeUpgradeTest {
@Mock
private TbContext ctx;
@Mock
private AttributesService attributesService;
private TbGpsGeofencingActionNode node;
@BeforeEach
void setUp() {
node = spy(new TbGpsGeofencingActionNode());
}
@AfterEach
void tearDown() {
node.destroy();
}
private static Stream<Arguments> givenReportPresenceStatusOnEachMessage_whenOnMsg_thenVerifyOutputMsgType() {
DeviceId deviceId = new DeviceId(UUID.randomUUID());
long tsNow = System.currentTimeMillis();
long tsNowMinusMinuteAndMillis = tsNow - Duration.ofMinutes(1).plusMillis(1).toMillis();
return Stream.of(
// default config with presenceMonitoringStrategyOnEachMessage false and msgInside true
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, false,
new EntityGeofencingState(false, 0, false)), ENTERED),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, false,
new EntityGeofencingState(true, tsNow, false)), SUCCESS),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, false,
new EntityGeofencingState(true, tsNowMinusMinuteAndMillis, false)), INSIDE),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, false,
new EntityGeofencingState(true, tsNow, true)), SUCCESS),
// default config with presenceMonitoringStrategyOnEachMessage false and msgInside false
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, false,
new EntityGeofencingState(false, 0, false)), LEFT),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, false,
new EntityGeofencingState(false, tsNow, false)), SUCCESS),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, false,
new EntityGeofencingState(false, tsNowMinusMinuteAndMillis, false)), OUTSIDE),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, false,
new EntityGeofencingState(false, tsNow, true)), SUCCESS),
// default config with presenceMonitoringStrategyOnEachMessage true and msgInside true
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, true,
new EntityGeofencingState(false, 0, false)), ENTERED),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, true,
new EntityGeofencingState(true, tsNow, false)), INSIDE),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, true,
new EntityGeofencingState(true, tsNowMinusMinuteAndMillis, false)), INSIDE),
// default config with presenceMonitoringStrategyOnEachMessage true and msgInside false
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, true,
new EntityGeofencingState(false, 0, false)), LEFT),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, true,
new EntityGeofencingState(false, tsNow, false)), OUTSIDE),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, true,
new EntityGeofencingState(false, tsNowMinusMinuteAndMillis, false)), OUTSIDE)
);
}
@ParameterizedTest
@MethodSource
void givenReportPresenceStatusOnEachMessage_whenOnMsg_thenVerifyOutputMsgType(
GpsGeofencingActionTestCase gpsGeofencingActionTestCase,
String expectedOutput
) throws TbNodeException {
// GIVEN
var config = new TbGpsGeofencingActionNodeConfiguration().defaultConfiguration();
config.setReportPresenceStatusOnEachMessage(gpsGeofencingActionTestCase.isReportPresenceStatusOnEachMessage());
node.init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
TbMsg msg = gpsGeofencingActionTestCase.isMsgInside() ?
getInsideRectangleTbMsg(gpsGeofencingActionTestCase.getEntityId()) :
getOutsideRectangleTbMsg(gpsGeofencingActionTestCase.getEntityId());
when(ctx.getAttributesService()).thenReturn(attributesService);
ReflectionTestUtils.setField(node, "entityStates", gpsGeofencingActionTestCase.getEntityStates());
// WHEN
node.onMsg(ctx, msg);
// THEN
verify(ctx.getAttributesService(), never()).find(any(), any(), any(), anyString());
verify(ctx, never()).tellFailure(any(), any(Throwable.class));
verify(ctx, never()).enqueueForTellNext(any(), eq(expectedOutput), any(), any());
verify(ctx, never()).ack(any());
if (SUCCESS.equals(expectedOutput)) {
verify(ctx).tellSuccess(eq(msg));
} else {
verify(ctx).tellNext(eq(msg), eq(expectedOutput));
}
}
private TbMsg getOutsideRectangleTbMsg(EntityId entityId) {
return getTbMsg(entityId, getMetadataForNewVersionPolygonPerimeter(),
GeoUtilTest.POINT_OUTSIDE_SIMPLE_RECT.getLatitude(),
GeoUtilTest.POINT_OUTSIDE_SIMPLE_RECT.getLongitude());
}
private TbMsg getInsideRectangleTbMsg(EntityId entityId) {
return getTbMsg(entityId, getMetadataForNewVersionPolygonPerimeter(),
GeoUtilTest.POINT_INSIDE_SIMPLE_RECT_CENTER.getLatitude(),
GeoUtilTest.POINT_INSIDE_SIMPLE_RECT_CENTER.getLongitude());
}
private TbMsg getTbMsg(EntityId entityId, TbMsgMetaData metadata, double latitude, double longitude) {
String data = "{\"latitude\": " + latitude + ", \"longitude\": " + longitude + "}";
return TbMsg.newMsg(TbMsgType.POST_ATTRIBUTES_REQUEST, entityId, metadata, data);
}
private TbMsgMetaData getMetadataForNewVersionPolygonPerimeter() {
var metadata = new TbMsgMetaData();
metadata.putValue("ss_perimeter", GeoUtilTest.SIMPLE_RECT);
return metadata;
}
// Rule nodes upgrade
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of(
// default config for version 0
Arguments.of(0,
"{\n" +
" \"minInsideDuration\": 1,\n" +
" \"minOutsideDuration\": 1,\n" +
" \"minInsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"minOutsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"latitudeKeyName\": \"latitude\",\n" +
" \"longitudeKeyName\": \"longitude\",\n" +
" \"perimeterType\": \"POLYGON\",\n" +
" \"fetchPerimeterInfoFromMessageMetadata\": true,\n" +
" \"perimeterKeyName\": \"ss_perimeter\",\n" +
" \"polygonsDefinition\": null,\n" +
" \"centerLatitude\": null,\n" +
" \"centerLongitude\": null,\n" +
" \"range\": null,\n" +
" \"rangeUnit\": null\n" +
"}\n",
true,
"{\n" +
" \"minInsideDuration\": 1,\n" +
" \"minOutsideDuration\": 1,\n" +
" \"minInsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"minOutsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"reportPresenceStatusOnEachMessage\": false,\n" +
" \"latitudeKeyName\": \"latitude\",\n" +
" \"longitudeKeyName\": \"longitude\",\n" +
" \"perimeterType\": \"POLYGON\",\n" +
" \"fetchPerimeterInfoFromMessageMetadata\": true,\n" +
" \"perimeterKeyName\": \"ss_perimeter\",\n" +
" \"polygonsDefinition\": null,\n" +
" \"centerLatitude\": null,\n" +
" \"centerLongitude\": null,\n" +
" \"range\": null,\n" +
" \"rangeUnit\": null\n" +
"}\n"),
// default config for version 1 with upgrade from version 0
Arguments.of(0,
"{\n" +
" \"minInsideDuration\": 1,\n" +
" \"minOutsideDuration\": 1,\n" +
" \"minInsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"minOutsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"reportPresenceStatusOnEachMessage\": false,\n" +
" \"latitudeKeyName\": \"latitude\",\n" +
" \"longitudeKeyName\": \"longitude\",\n" +
" \"perimeterType\": \"POLYGON\",\n" +
" \"fetchPerimeterInfoFromMessageMetadata\": true,\n" +
" \"perimeterKeyName\": \"ss_perimeter\",\n" +
" \"polygonsDefinition\": null,\n" +
" \"centerLatitude\": null,\n" +
" \"centerLongitude\": null,\n" +
" \"range\": null,\n" +
" \"rangeUnit\": null\n" +
"}\n",
false,
"{\n" +
" \"minInsideDuration\": 1,\n" +
" \"minOutsideDuration\": 1,\n" +
" \"minInsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"minOutsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"reportPresenceStatusOnEachMessage\": false,\n" +
" \"latitudeKeyName\": \"latitude\",\n" +
" \"longitudeKeyName\": \"longitude\",\n" +
" \"perimeterType\": \"POLYGON\",\n" +
" \"fetchPerimeterInfoFromMessageMetadata\": true,\n" +
" \"perimeterKeyName\": \"ss_perimeter\",\n" +
" \"polygonsDefinition\": null,\n" +
" \"centerLatitude\": null,\n" +
" \"centerLongitude\": null,\n" +
" \"range\": null,\n" +
" \"rangeUnit\": null\n" +
"}\n")
);
}
@Override
protected TbNode getTestNode() {
return node;
}
}

18
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoaderTest.java

@ -37,10 +37,12 @@ import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.AlarmId;
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.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.EntityViewId;
@ -52,6 +54,7 @@ import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.dao.asset.AssetService;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.edge.EdgeService;
import org.thingsboard.server.dao.entityview.EntityViewService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.tenant.TenantService;
@ -95,6 +98,8 @@ public class EntitiesFieldsAsyncLoaderTest {
private RuleChainService ruleChainServiceMock;
@Mock
private EntityViewService entityViewServiceMock;
@Mock
private EdgeService edgeServiceMock;
@BeforeAll
public static void setup() {
@ -108,7 +113,8 @@ public class EntitiesFieldsAsyncLoaderTest {
EntityType.DEVICE,
EntityType.ALARM,
EntityType.RULE_CHAIN,
EntityType.ENTITY_VIEW
EntityType.ENTITY_VIEW,
EntityType.EDGE
);
}
@ -228,6 +234,14 @@ public class EntitiesFieldsAsyncLoaderTest {
when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock);
doReturn(entityView).when(entityViewServiceMock).findEntityViewByIdAsync(eq(TENANT_ID), any());
break;
case EDGE:
var edge = Futures.immediateFuture(entityDoesNotExist ? null : new Edge(new EdgeId(RANDOM_UUID)));
when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR);
when(ctxMock.getEdgeService()).thenReturn(edgeServiceMock);
doReturn(edge).when(edgeServiceMock).findEdgeByIdAsync(eq(TENANT_ID), any());
break;
default:
throw new RuntimeException("Unexpected EntityType: " + entityType);
@ -252,6 +266,8 @@ public class EntitiesFieldsAsyncLoaderTest {
return new RuleChain((RuleChainId) entityId);
case ENTITY_VIEW:
return new EntityView((EntityViewId) entityId);
case EDGE:
return new Edge((EdgeId) entityId);
default:
throw new RuntimeException("Unexpected EntityType: " + entityId.getEntityType());
}

1
ui-ngx/package.json

@ -44,6 +44,7 @@
"@ngrx/store-devtools": "^15.4.0",
"@ngx-translate/core": "^14.0.0",
"@ngx-translate/http-loader": "^7.0.0",
"@svgdotjs/svg.filter.js": "^3.0.8",
"@svgdotjs/svg.js": "^3.2.0",
"@tinymce/tinymce-angular": "^7.0.0",
"ace-builds": "1.4.13",

1
ui-ngx/src/app/core/api/widget-api.models.ts

@ -82,6 +82,7 @@ export interface RpcApi {
export interface IWidgetUtils {
formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined;
getEntityDetailsPageURL: (id: string, entityType: EntityType) => string;
}
export interface WidgetActionsApi {

37
ui-ngx/src/app/core/http/image.service.ts

@ -18,7 +18,7 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { PageLink } from '@shared/models/page/page-link';
import { defaultHttpOptionsFromConfig, defaultHttpUploadOptions, RequestConfig } from '@core/http/http-utils';
import { Observable, of } from 'rxjs';
import { Observable, of, ReplaySubject } from 'rxjs';
import { PageData } from '@shared/models/page/page-data';
import {
NO_IMAGE_DATA_URI,
@ -36,6 +36,9 @@ import { ResourcesService } from '@core/services/resources.service';
providedIn: 'root'
})
export class ImageService {
private imagesLoading: { [url: string]: ReplaySubject<Blob> } = {};
constructor(
private http: HttpClient,
private sanitizer: DomSanitizer,
@ -95,12 +98,34 @@ export class ImageService {
parts[parts.length - 1] = encodeURIComponent(key);
const encodedUrl = parts.join('/');
const imageLink = preview ? (encodedUrl + '/preview') : encodedUrl;
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
return this.http
.get(imageLink, {...options, ...{ responseType: 'blob' } }).pipe(
return this.loadImageDataUrl(imageLink, asString, emptyUrl);
}
private loadImageDataUrl(imageLink: string, asString = false, emptyUrl = NO_IMAGE_DATA_URI): Observable<SafeUrl | string> {
let request: ReplaySubject<Blob>;
if (this.imagesLoading[imageLink]) {
request = this.imagesLoading[imageLink];
} else {
request = new ReplaySubject<Blob>(1);
this.imagesLoading[imageLink] = request;
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
this.http.get(imageLink, {...options, ...{ responseType: 'blob' } }).subscribe({
next: (value) => {
request.next(value);
request.complete();
},
error: err => {
request.error(err);
},
complete: () => {
delete this.imagesLoading[imageLink];
}
});
}
return request.pipe(
switchMap(val => blobToBase64(val).pipe(
map((dataUrl) => asString ? dataUrl : this.sanitizer.bypassSecurityTrustUrl(dataUrl))
)),
map((dataUrl) => asString ? dataUrl : this.sanitizer.bypassSecurityTrustUrl(dataUrl))
)),
catchError(() => of(asString ? emptyUrl : this.sanitizer.bypassSecurityTrustUrl(emptyUrl)))
);
}

1
ui-ngx/src/app/core/notification/notification.models.ts

@ -33,6 +33,7 @@ export class NotificationMessage {
horizontalPosition?: NotificationHorizontalPosition;
verticalPosition?: NotificationVerticalPosition;
panelClass?: string | string[];
modern?: boolean;
}
export class HideNotification {

8
ui-ngx/src/app/core/services/item-buffer.service.ts

@ -27,7 +27,7 @@ import {
widgetType
} from '@shared/models/widget.models';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
import { deepClone, isEqual } from '@core/utils';
import { deepClone, isDefinedAndNotNull, isEqual } from '@core/utils';
import { UtilsService } from '@core/services/utils.service';
import { Observable, of, throwError } from 'rxjs';
import { map } from 'rxjs/operators';
@ -309,6 +309,12 @@ export class ItemBufferService {
if (origNode.error) {
node.error = origNode.error;
}
if (isDefinedAndNotNull(origNode.singletonMode)) {
node.singletonMode = origNode.singletonMode;
}
if (isDefinedAndNotNull(origNode.queueName)) {
node.queueName = origNode.queueName;
}
ruleNodes.nodes.push(node);
if (i === 0) {
top = node.y;

30
ui-ngx/src/app/modules/common/modules-map.ts

@ -194,6 +194,17 @@ import * as ScrollGridComponent from '@shared/components/grid/scroll-grid.compon
import * as GalleryImageInputComponent from '@shared/components/image/gallery-image-input.component';
import * as MultipleGalleryImageInputComponent from '@shared/components/image/multiple-gallery-image-input.component';
import * as CssUnitSelectComponent from '@home/components/widget/lib/settings/common/css-unit-select.component';
import * as WidgetActionsPanelComponent from '@home/components/widget/config/basic/common/widget-actions-panel.component';
import * as FontSettingsComponent from '@home/components/widget/lib/settings/common/font-settings.component';
import * as ColorSettingsComponent from '@home/components/widget/lib/settings/common/color-settings.component';
import * as DisplayColumnsPanelComponent from '@home/components/widget/lib/display-columns-panel.component';
import * as AlarmDetailsDialogComponent from '@home/components/alarm/alarm-details-dialog.component';
import * as AlarmAssigneePanelComponent from '@home/components/alarm/alarm-assignee-panel.component';
import * as AlarmCommentDialogComponent from '@home/components/alarm/alarm-comment-dialog.component';
import * as AlarmFilterConfigComponent from '@home/components/alarm/alarm-filter-config.component';
import * as DatasourceComponent from '@home/components/widget/config/datasources.component';
import * as DataKeysPanelComponent from '@home/components/widget/config/basic/common/data-keys-panel.component';
import * as AddEntityDialogComponent from '@home/components/entity/add-entity-dialog.component';
import * as EntitiesTableComponent from '@home/components/entity/entities-table.component';
import * as DetailsPanelComponent from '@home/components/details-panel.component';
@ -239,6 +250,10 @@ import * as ImportDialogCsvComponent from '@shared/import-export/import-dialog-c
import * as TableColumnsAssignmentComponent from '@shared/import-export/table-columns-assignment.component';
import * as EventContentDialogComponent from '@home/components/event/event-content-dialog.component';
import * as SharedHomeComponentsModule from '@home/components/shared-home-components.module';
import * as WidgetConfigComponentsModule from '@home/components/widget/config/widget-config-components.module';
import * as BasicWidgetConfigModule from '@home/components/widget/config/basic/basic-widget-config.module';
import * as WidgetSettingsCommonModule from '@home/components/widget/lib/settings/common/widget-settings-common.module';
import * as WidgetComponentsModule from '@home/components/widget/widget-components.module';
import * as SelectTargetLayoutDialogComponent from '@home/components/dashboard/select-target-layout-dialog.component';
import * as SelectTargetStateDialogComponent from '@home/components/dashboard/select-target-state-dialog.component';
import * as AliasesEntityAutocompleteComponent from '@home/components/alias/aliases-entity-autocomplete.component';
@ -507,6 +522,17 @@ class ModulesMap implements IModulesMap {
'@shared/components/image/gallery-image-input.component': GalleryImageInputComponent,
'@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent,
'@home/components/alarm/alarm-filter-config.component': AlarmFilterConfigComponent,
'@home/components/alarm/alarm-comment-dialog.component': AlarmCommentDialogComponent,
'@home/components/alarm/alarm-assignee-panel.component': AlarmAssigneePanelComponent,
'@home/components/alarm/alarm-details-dialog.component': AlarmDetailsDialogComponent,
'@home/components/widget/lib/display-columns-panel.component': DisplayColumnsPanelComponent,
'@home/components/widget/config/datasources.component': DatasourceComponent,
'@home/components/widget/config/basic/common/data-keys-panel.component': DataKeysPanelComponent,
'@home/components/widget/lib/settings/common/color-settings.component': ColorSettingsComponent,
'@home/components/widget/lib/settings/common/font-settings.component': FontSettingsComponent,
'@home/components/widget/config/basic/common/widget-actions-panel.component': WidgetActionsPanelComponent,
'@home/components/widget/lib/settings/common/css-unit-select.component': CssUnitSelectComponent,
'@home/components/entity/add-entity-dialog.component': AddEntityDialogComponent,
'@home/components/entity/entities-table.component': EntitiesTableComponent,
'@home/components/details-panel.component': DetailsPanelComponent,
@ -549,6 +575,10 @@ class ModulesMap implements IModulesMap {
'@home/components/attribute/add-widget-to-dashboard-dialog.component': AddWidgetToDashboardDialogComponent,
'@home/components/event/event-content-dialog.component': EventContentDialogComponent,
'@home/components/shared-home-components.module': SharedHomeComponentsModule,
'@home/components/widget/config/widget-config-components.module': WidgetConfigComponentsModule,
'@home/components/widget/config/basic/basic-widget-config.module': BasicWidgetConfigModule,
'@home/components/widget/lib/settings/common/widget-settings-common.module': WidgetSettingsCommonModule,
'@home/components/widget/widget-components.module': WidgetComponentsModule,
'@home/components/dashboard/select-target-layout-dialog.component': SelectTargetLayoutDialogComponent,
'@home/components/dashboard/select-target-state-dialog.component': SelectTargetStateDialogComponent,
'@home/components/alias/aliases-entity-autocomplete.component': AliasesEntityAutocompleteComponent,

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

@ -214,7 +214,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo
disableAutoPositionOnConflict: false,
pushItems: false,
swap: false,
maxRows: 100,
maxRows: 3000,
minCols: this.columns ? this.columns : 24,
maxCols: 3000,
maxItemCols: 1000,
@ -293,12 +293,12 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo
this.dashboardTimewindowChangedSubject.next(this.dashboardTimewindow);
}
if (updateMobileOpts) {
this.updateMobileOpts();
}
if (updateLayoutOpts) {
this.updateLayoutOpts();
}
if (updateMobileOpts) {
this.updateMobileOpts();
}
if (updateEditingOpts) {
this.updateEditingOpts();
}

2
ui-ngx/src/app/modules/home/components/filter/filters-edit.component.ts

@ -119,7 +119,7 @@ export class FiltersEditComponent implements OnInit, OnDestroy {
const filteredArray = Object.entries(this.filtersInfo);
if (filteredArray.length === 1) {
const singleFilter: Filter = {id: filteredArray[0][0], ...filteredArray[0][1]};
const singleFilter: Filter = {id: filteredArray[0][0], ...deepClone(filteredArray[0][1])};
this.dialog.open<UserFilterDialogComponent, UserFilterDialogData,
Filter>(UserFilterDialogComponent, {
disableClose: true,

2
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html

@ -19,7 +19,7 @@
<div *ngFor="let deviceProfileCommunication of communicationConfigFormArray.controls; let $index = index;
last as isLast;" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" class="scope-row" [formGroup]="deviceProfileCommunication">
<div class="communication-config" fxFlex fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start">
<div class="communication-config">
<mat-form-field class="spec mat-block" floatLabel="always" hideRequiredMarker>
<mat-label translate>device-profile.snmp.scope</mat-label>
<mat-select formControlName="spec" required>

15
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.scss

@ -13,12 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../scss/constants';
:host {
.communication-config {
border: 2px groove rgba(0, 0, 0, 0.25);
border-radius: 4px;
padding: 8px;
min-width: 0;
flex-direction: column;
display: flex;
place-content: stretch flex-start;
align-items: stretch;
flex: 1;
gap: 0;
}
.scope-row {
@ -28,6 +36,13 @@
.required-text {
margin: 16px 0
}
@media #{$mat-gt-xmd} {
.communication-config {
flex-direction: row;
gap: 8px;
}
}
}
:host ::ng-deep {

6
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html

@ -19,8 +19,8 @@
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px" fxFlex="100">
<div fxFlex fxLayout="row" fxLayoutGap="8px">
<label fxFlex="26" class="tb-title no-padding" translate>device-profile.snmp.data-type</label>
<label fxFlex="37" class="tb-title no-padding" translate>device-profile.snmp.data-key</label>
<label fxFlex="37" class="tb-title no-padding" translate>device-profile.snmp.oid</label>
<label fxFlex="37" class="tb-title tb-required no-padding" translate>device-profile.snmp.data-key</label>
<label fxFlex="37" class="tb-title tb-required no-padding" translate>device-profile.snmp.oid</label>
<span style="min-width: 40px" [fxShow]="!disabled"></span>
</div>
</div>
@ -28,7 +28,7 @@
<div *ngFor="let mappingConfig of mappingsConfigFormArray.controls; let $index = index;
last as isLast;" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" [formGroup]="mappingConfig" class="mapping-list">
<div fxFlex fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start">
<div class="tb-layout-fill" fxFlex fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start">
<mat-form-field fxFlex="26">
<mat-select formControlName="dataType" required>
<mat-option *ngFor="let dataType of dataTypes" [value]="dataType">

10
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.scss

@ -15,13 +15,9 @@
*/
:host {
.mapping-config {
min-width: 518px;
}
.mapping-list {
padding-bottom: 8px;
}
.required-text {
margin: 14px 0;
.required-text {
margin: 14px 0;
}
}
}

6
ui-ngx/src/app/modules/home/components/public-api.ts

@ -15,5 +15,11 @@
///
export * from './home-components.module';
export * from './widget/config/basic/basic-widget-config.module';
export * from './widget/lib/settings/common/widget-settings-common.module';
export * from './widget/widget-components.module';
export * from './widget/config/widget-config-components.module';
export * from './widget/config/widget-config.component.models';
export * from './widget/lib/table-widget.models';
export * from './widget/lib/flot-widget.models';

4
ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html

@ -26,9 +26,9 @@
<span fxFlex></span>
<div tb-help="repositorySettings"></div>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="!hideLoadingBar && isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div style="height: 4px;" *ngIf="hideLoadingBar || !(isLoading$ | async)"></div>
<mat-card-content style="padding-top: 16px;">
<form [formGroup]="repositorySettingsForm" #formDirective="ngForm" (ngSubmit)="save()">
<fieldset [disabled]="isLoading$ | async">

5
ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts

@ -34,6 +34,7 @@ import { selectHasRepository } from '@core/auth/auth.selectors';
import { catchError, mergeMap, take } from 'rxjs/operators';
import { of } from 'rxjs';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
selector: 'tb-repository-settings',
@ -48,6 +49,10 @@ export class RepositorySettingsComponent extends PageComponent implements OnInit
@Input()
popoverComponent: TbPopoverComponent;
@Input()
@coerceBoolean()
hideLoadingBar = false;
repositorySettingsForm: UntypedFormGroup;
settings: RepositorySettings = null;

6
ui-ngx/src/app/modules/home/components/vc/version-control.component.html

@ -15,9 +15,11 @@
limitations under the License.
-->
<tb-repository-settings #repositorySettingsComponent [detailsMode]="detailsMode"
<tb-repository-settings #repositorySettingsComponent
[detailsMode]="detailsMode"
hideLoadingBar
[popoverComponent]="popoverComponent"
*ngIf="!(hasRepository$ | async); else versionsTable">
*ngIf="!(hasRepository$ | async); else versionsTable">
</tb-repository-settings>
<ng-template #versionsTable>
<tb-entity-versions-table [singleEntityMode]="singleEntityMode"

16
ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts

@ -100,6 +100,10 @@ import {
import {
CommandButtonBasicConfigComponent
} from '@home/components/widget/config/basic/button/command-button-basic-config.component';
import {
PowerButtonBasicConfigComponent
} from '@home/components/widget/config/basic/button/power-button-basic-config.component';
import { SliderBasicConfigComponent } from '@home/components/widget/config/basic/rpc/slider-basic-config.component';
@NgModule({
declarations: [
@ -131,7 +135,9 @@ import {
BarChartWithLabelsBasicConfigComponent,
SingleSwitchBasicConfigComponent,
ActionButtonBasicConfigComponent,
CommandButtonBasicConfigComponent
CommandButtonBasicConfigComponent,
PowerButtonBasicConfigComponent,
SliderBasicConfigComponent
],
imports: [
CommonModule,
@ -167,7 +173,9 @@ import {
BarChartWithLabelsBasicConfigComponent,
SingleSwitchBasicConfigComponent,
ActionButtonBasicConfigComponent,
CommandButtonBasicConfigComponent
CommandButtonBasicConfigComponent,
PowerButtonBasicConfigComponent,
SliderBasicConfigComponent
]
})
export class BasicWidgetConfigModule {
@ -197,5 +205,7 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type<IBasicWidgetCo
'tb-bar-chart-with-labels-basic-config': BarChartWithLabelsBasicConfigComponent,
'tb-single-switch-basic-config': SingleSwitchBasicConfigComponent,
'tb-action-button-basic-config': ActionButtonBasicConfigComponent,
'tb-command-button-basic-config': CommandButtonBasicConfigComponent
'tb-command-button-basic-config': CommandButtonBasicConfigComponent,
'tb-power-button-basic-config': PowerButtonBasicConfigComponent,
'tb-slider-basic-config': SliderBasicConfigComponent
};

197
ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html

@ -0,0 +1,197 @@
<!--
Copyright © 2016-2024 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.
-->
<ng-container [formGroup]="powerButtonWidgetConfigForm">
<tb-target-device formControlName="targetDevice"></tb-target-device>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.power-button.behavior</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.initial-state-hint' | translate}}" translate>widgets.rpc-state.initial-state</div>
<tb-get-value-action-settings fxFlex
panelTitle="widgets.rpc-state.initial-state"
[valueType]="valueType.BOOLEAN"
trueLabel="widgets.rpc-state.on"
falseLabel="widgets.rpc-state.off"
stateLabel="widgets.rpc-state.on"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="initialState"></tb-get-value-action-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.power-button.power-on-hint' | translate}}" translate>widgets.power-button.power-on</div>
<tb-set-value-action-settings fxFlex
panelTitle="widgets.power-button.power-on "
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="onUpdateState"></tb-set-value-action-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.power-button.power-off-hint' | translate}}" translate>widgets.power-button.power-off</div>
<tb-set-value-action-settings fxFlex
panelTitle="widgets.power-button.power-off"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="offUpdateState"></tb-set-value-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.disabled-state-hint' | translate}}" translate>widgets.rpc-state.disabled-state</div>
<tb-get-value-action-settings fxFlex
panelTitle="widgets.rpc-state.disabled-state"
[valueType]="valueType.BOOLEAN"
stateLabel="widgets.rpc-state.disabled"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="disabledState"></tb-get-value-action-settings>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="1:1"
[cols]="{columns: 3,
breakpoints: {
'lt-sm': 1,
'lt-md': 2
}}"
label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of powerButtonLayouts"
[value]="layout"
[image]="powerButtonLayoutImageMap.get(layout)">
{{ powerButtonLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle">
{{ 'widget-config.title' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="titleFont"
clearButton
[previewText]="powerButtonWidgetConfigForm.get('title').value"
[initialPreviewStyle]="widgetConfig.config.titleStyle">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="titleColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widget-config.card-icon' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
[color]="powerButtonWidgetConfigForm.get('iconColor').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
colorClearButton
formControlName="iconColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.power-button.power-on-colors' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.main</div>
<tb-color-input asBoxInput
formControlName="mainColorOn">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColorOn">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.power-button.power-off-colors' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.main</div>
<tb-color-input asBoxInput
formControlName="mainColorOff">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColorOff">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.power-button.disabled-colors' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.main</div>
<tb-color-input asBoxInput
formControlName="mainColorDisabled">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColorDisabled">
</tb-color-input>
</div>
</div>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-border-radius' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<tb-widget-actions-panel
formControlName="actions">
</tb-widget-actions-panel>
</ng-container>

195
ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts

@ -0,0 +1,195 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { TargetDevice, WidgetConfig, } from '@shared/models/widget.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { isUndefined } from '@core/utils';
import { ValueType } from '@shared/models/constants';
import {
powerButtonDefaultSettings,
powerButtonLayoutImages,
powerButtonLayouts,
powerButtonLayoutTranslations,
PowerButtonWidgetSettings
} from '@home/components/widget/lib/rpc/power-button-widget.models';
import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings.models';
@Component({
selector: 'tb-power-button-basic-config',
templateUrl: './power-button-basic-config.component.html',
styleUrls: ['../basic-config.scss']
})
export class PowerButtonBasicConfigComponent extends BasicWidgetConfigComponent {
get targetDevice(): TargetDevice {
return this.powerButtonWidgetConfigForm.get('targetDevice').value;
}
powerButtonLayouts = powerButtonLayouts;
powerButtonLayoutTranslationMap = powerButtonLayoutTranslations;
powerButtonLayoutImageMap = powerButtonLayoutImages;
valueType = ValueType;
powerButtonWidgetConfigForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.powerButtonWidgetConfigForm;
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: PowerButtonWidgetSettings = {...powerButtonDefaultSettings, ...(configData.config.settings || {})};
const iconSize = resolveCssSize(configData.config.iconSize);
this.powerButtonWidgetConfigForm = this.fb.group({
targetDevice: [configData.config.targetDevice, []],
initialState: [settings.initialState, []],
onUpdateState: [settings.onUpdateState, []],
offUpdateState: [settings.offUpdateState, []],
disabledState: [settings.disabledState, []],
layout: [settings.layout, []],
showTitle: [configData.config.showTitle, []],
title: [configData.config.title, []],
titleFont: [configData.config.titleFont, []],
titleColor: [configData.config.titleColor, []],
showIcon: [configData.config.showTitleIcon, []],
iconSize: [iconSize[0], [Validators.min(0)]],
iconSizeUnit: [iconSize[1], []],
icon: [configData.config.titleIcon, []],
iconColor: [configData.config.iconColor, []],
mainColorOn: [settings.mainColorOn, []],
backgroundColorOn: [settings.backgroundColorOn, []],
mainColorOff: [settings.mainColorOff, []],
backgroundColorOff: [settings.backgroundColorOff, []],
mainColorDisabled: [settings.mainColorDisabled, []],
backgroundColorDisabled: [settings.backgroundColorDisabled, []],
background: [settings.background, []],
cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []],
actions: [configData.config.actions || {}, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.targetDevice = config.targetDevice;
this.widgetConfig.config.showTitle = config.showTitle;
this.widgetConfig.config.title = config.title;
this.widgetConfig.config.titleFont = config.titleFont;
this.widgetConfig.config.titleColor = config.titleColor;
this.widgetConfig.config.showTitleIcon = config.showIcon;
this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit);
this.widgetConfig.config.titleIcon = config.icon;
this.widgetConfig.config.iconColor = config.iconColor;
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {};
this.widgetConfig.config.settings.initialState = config.initialState;
this.widgetConfig.config.settings.onUpdateState = config.onUpdateState;
this.widgetConfig.config.settings.offUpdateState = config.offUpdateState;
this.widgetConfig.config.settings.disabledState = config.disabledState;
this.widgetConfig.config.settings.layout = config.layout;
this.widgetConfig.config.settings.mainColorOn = config.mainColorOn;
this.widgetConfig.config.settings.backgroundColorOn = config.backgroundColorOn;
this.widgetConfig.config.settings.mainColorOff = config.mainColorOff;
this.widgetConfig.config.settings.backgroundColorOff = config.backgroundColorOff;
this.widgetConfig.config.settings.mainColorDisabled = config.mainColorDisabled;
this.widgetConfig.config.settings.backgroundColorDisabled = config.backgroundColorDisabled;
this.widgetConfig.config.settings.background = config.background;
this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.actions = config.actions;
return this.widgetConfig;
}
protected validatorTriggers(): string[] {
return ['showTitle', 'showIcon'];
}
protected updateValidators(emitEvent: boolean, trigger?: string) {
const showTitle: boolean = this.powerButtonWidgetConfigForm.get('showTitle').value;
const showIcon: boolean = this.powerButtonWidgetConfigForm.get('showIcon').value;
if (showTitle) {
this.powerButtonWidgetConfigForm.get('title').enable();
this.powerButtonWidgetConfigForm.get('titleFont').enable();
this.powerButtonWidgetConfigForm.get('titleColor').enable();
this.powerButtonWidgetConfigForm.get('showIcon').enable({emitEvent: false});
if (showIcon) {
this.powerButtonWidgetConfigForm.get('iconSize').enable();
this.powerButtonWidgetConfigForm.get('iconSizeUnit').enable();
this.powerButtonWidgetConfigForm.get('icon').enable();
this.powerButtonWidgetConfigForm.get('iconColor').enable();
} else {
this.powerButtonWidgetConfigForm.get('iconSize').disable();
this.powerButtonWidgetConfigForm.get('iconSizeUnit').disable();
this.powerButtonWidgetConfigForm.get('icon').disable();
this.powerButtonWidgetConfigForm.get('iconColor').disable();
}
} else {
this.powerButtonWidgetConfigForm.get('title').disable();
this.powerButtonWidgetConfigForm.get('titleFont').disable();
this.powerButtonWidgetConfigForm.get('titleColor').disable();
this.powerButtonWidgetConfigForm.get('showIcon').disable({emitEvent: false});
this.powerButtonWidgetConfigForm.get('iconSize').disable();
this.powerButtonWidgetConfigForm.get('iconSizeUnit').disable();
this.powerButtonWidgetConfigForm.get('icon').disable();
this.powerButtonWidgetConfigForm.get('iconColor').disable();
}
}
private getCardButtons(config: WidgetConfig): string[] {
const buttons: string[] = [];
if (isUndefined(config.enableFullscreen) || config.enableFullscreen) {
buttons.push('fullscreen');
}
return buttons;
}
private setCardButtons(buttons: string[], config: WidgetConfig) {
config.enableFullscreen = buttons.includes('fullscreen');
}
}

4
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.html

@ -86,7 +86,7 @@
</tb-color-input>
</div>
</div>
<div class="tb-form-row">
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showValue">
{{ 'widgets.progress-bar.value' | translate }}
</mat-slide-toggle>
@ -104,7 +104,7 @@
</tb-color-settings>
</div>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.progress-bar.range' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-small-label" translate>widgets.progress-bar.min</div>

50
ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.html

@ -34,7 +34,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle">
{{ 'widgets.liquid-level-card.title' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
@ -49,7 +49,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitleIcon">
{{ 'widgets.liquid-level-card.icon' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
@ -64,7 +64,7 @@
</div>
</div>
<div fxFlex fxLayout="column" class="tb-form-row space-between">
<div fxFlex fxLayout="row" style="width: 100%;" fxLayoutAlign="space-between center">
<div class="tb-flex row space-between align-center no-gap fill-width">
<div class="fixed-title-width" translate>widgets.liquid-level-card.shape</div>
<tb-toggle-select formControlName="tankSelectionType">
<tb-toggle-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -96,7 +96,7 @@
formControlName="shapeAttributeName">
</tb-string-autocomplete>
</div>
<div fxFlex fxLayout="row" fxLayoutAlign="space-between center" style="width: 100%;">
<div class="tb-flex row space-between align-center no-gap fill-width">
<div class="tb-hint" style="padding: 0;" translate>widgets.liquid-level-card.shape-by-attribute</div>
<div class="see-example"
[tb-help-popup]="'widget/lib/indicator/shape_attribute_fn'"
@ -123,16 +123,16 @@
<div class="tb-form-panel-title" translate>widgets.liquid-level-card.units</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.datasource-units</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center">
<div class="tb-flex row flex-end align-center no-gap">
<tb-unit-input required style="max-width: 25%;" class="flex"
[tagFilter]="unitsType.capacity"
formControlName="datasourceUnits">
</tb-unit-input>
</div>
</div>
<div class="tb-form-row space-between" *ngIf="levelCardWidgetConfigForm.get('layout').value === LevelCardLayout.absolute">
<div class="tb-form-row space-between column-xs" *ngIf="levelCardWidgetConfigForm.get('layout').value === LevelCardLayout.absolute">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.widget-units</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="widgetUnitsSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -154,9 +154,9 @@
</ng-template>
</div>
</div>
<div class="tb-form-row" [fxShow]="volumeInput">
<div class="tb-form-row column-xs" [fxShow]="volumeInput">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.total-volume</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="volumeSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -184,10 +184,30 @@
formControlName="volumeAttributeName">
</tb-string-autocomplete>
</ng-template>
<tb-unit-input [tagFilter]="unitsType.capacity"
required style="max-width: 25%" class="flex"
</div>
</div>
<div class="tb-form-row space-between column-xs" [fxShow]="volumeInput">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.total-volume-units</div>
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="volumeUnitsSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
{{ DataSourceTypeTranslations.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-unit-input *ngIf="levelCardWidgetConfigForm.get('volumeUnitsSource').value === DataSourceType.static; else selectVolumeUnitsAttributes"
class="flex" required
[tagFilter]="unitsType.capacity"
formControlName="volumeUnits">
</tb-unit-input>
<ng-template #selectVolumeUnitsAttributes>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
required style="flex: 1"
[errorText]="'widgets.liquid-level-card.attribute-name-required' | translate"
formControlName="volumeUnitsAttributeName">
</tb-string-autocomplete>
</ng-template>
</div>
</div>
</div>
@ -206,7 +226,7 @@
<div class="tb-form-row space-between"
*ngIf="levelCardWidgetConfigForm.get('layout').value !== LevelCardLayout.simple">
<div class="fixed-title-width" translate>widgets.liquid-level-card.value</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<mat-form-field style="max-width: 40%;" appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput formControlName="decimals" type="number"
min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
@ -221,7 +241,7 @@
</div>
<div class="tb-form-row" *ngIf="levelCardWidgetConfigForm.get('layout').value === LevelCardLayout.absolute">
<div class="fixed-title-width" translate>widgets.liquid-level-card.total-volume</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<tb-font-settings formControlName="volumeFont"
[previewText]="totalVolumeValuePreviewFn">
</tb-font-settings>
@ -247,7 +267,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTooltipLevel">
{{ 'widgets.liquid-level-card.level' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<tb-unit-input required class="flex"
[tagFilter]="unitsType.capacity"
formControlName="tooltipUnits">
@ -268,7 +288,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTooltipDate">
{{ 'widgets.value-card.date' | translate }}
</mat-slide-toggle>
<div fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<tb-date-format-select fxFlex formControlName="tooltipDateFormat"></tb-date-format-select>
<tb-font-settings formControlName="tooltipDateFont"
[previewText]="datePreviewFn">

6
ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.ts

@ -194,7 +194,9 @@ export class LiquidLevelCardBasicConfigComponent extends BasicWidgetConfigCompon
volumeSource: [settings.volumeSource, []],
volumeConstant: [settings.volumeConstant, [Validators.required, Validators.min(0.1)]],
volumeAttributeName: [settings.volumeAttributeName, [Validators.required]],
volumeUnitsSource: [settings.volumeUnitsSource, []],
volumeUnits: [settings.volumeUnits, [Validators.required]],
volumeUnitsAttributeName: [settings.volumeUnitsAttributeName, [Validators.required]],
volumeFont: [settings.volumeFont, []],
volumeColor: [settings.volumeColor, []],
units: [settings.units, [Validators.required]],
@ -260,6 +262,8 @@ export class LiquidLevelCardBasicConfigComponent extends BasicWidgetConfigCompon
this.widgetConfig.config.settings.volumeSource = config.volumeSource;
this.widgetConfig.config.settings.volumeConstant = config.volumeConstant;
this.widgetConfig.config.settings.volumeAttributeName = config.volumeAttributeName;
this.widgetConfig.config.settings.volumeUnitsSource = config.volumeUnitsSource;
this.widgetConfig.config.settings.volumeUnitsAttributeName = config.volumeUnitsAttributeName;
this.widgetConfig.config.settings.volumeUnits = config.volumeUnits;
this.widgetConfig.config.settings.volumeFont = config.volumeFont;
this.widgetConfig.config.settings.volumeColor = config.volumeColor;
@ -294,7 +298,7 @@ export class LiquidLevelCardBasicConfigComponent extends BasicWidgetConfigCompon
protected validatorTriggers(): string[] {
return [
'showTooltip', 'showTooltipLevel', 'tankSelectionType', 'datasourceUnits', 'showTitleIcon', 'volumeSource',
'showTooltipDate', 'layout', 'showTitle', 'widgetUnitsSource'
'showTooltipDate', 'layout', 'showTitle', 'widgetUnitsSource', 'volumeUnitsSource'
];
}

4
ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html

@ -82,7 +82,7 @@
{{ 'widgets.single-switch.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel">
{{ 'widgets.single-switch.label' | translate }}
</mat-slide-toggle>
@ -99,7 +99,7 @@
</tb-color-input>
</div>
</div>
<div class="tb-form-row">
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.single-switch.icon' | translate }}
</mat-slide-toggle>

271
ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html

@ -0,0 +1,271 @@
<!--
Copyright © 2016-2024 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.
-->
<ng-container [formGroup]="sliderWidgetConfigForm">
<tb-target-device formControlName="targetDevice"></tb-target-device>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.slider.behavior</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.slider.initial-value-hint' | translate}}" translate>widgets.slider.initial-value</div>
<tb-get-value-action-settings fxFlex
panelTitle="widgets.slider.initial-value"
[valueType]="valueType.DOUBLE"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="initialState"></tb-get-value-action-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.slider.on-value-change-hint' | translate}}" translate>widgets.slider.on-value-change</div>
<tb-set-value-action-settings fxFlex
panelTitle="widgets.slider.on-value-change"
[valueType]="valueType.DOUBLE"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="valueChange"></tb-set-value-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.disabled-state-hint' | translate}}" translate>widgets.rpc-state.disabled-state</div>
<tb-get-value-action-settings fxFlex
panelTitle="widgets.rpc-state.disabled-state"
[valueType]="valueType.BOOLEAN"
stateLabel="widgets.rpc-state.disabled"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="disabledState"></tb-get-value-action-settings>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="2:1"
[cols]="{columns: 3,
breakpoints: {
'lt-sm': 1,
'lt-md': 2
}}"
label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of sliderLayouts"
[value]="layout"
[image]="sliderLayoutImageMap.get(layout)">
{{ sliderLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.slider.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle">
{{ 'widget-config.title' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="titleFont"
clearButton
[previewText]="sliderWidgetConfigForm.get('title').value"
[initialPreviewStyle]="widgetConfig.config.titleStyle">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="titleColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.slider.icon' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
[color]="sliderWidgetConfigForm.get('iconColor').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
colorClearButton
formControlName="iconColor">
</tb-color-input>
</div>
</div>
<div *ngIf="sliderWidgetConfigForm.get('layout').value !== sliderLayout.simplified" class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showValue">
{{ 'widgets.slider.value' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-unit-input class="flex" formControlName="valueUnits"></tb-unit-input>
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput formControlName="valueDecimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix fxHide.lt-md translate>widget-config.decimals-suffix</div>
</mat-form-field>
<tb-font-settings formControlName="valueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="valueColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.slider.range' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-small-label" translate>widgets.slider.min</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tickMin" type="number" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<div class="tb-small-label" translate>widgets.slider.max</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tickMax" type="number" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="showTicks">
{{ 'widgets.slider.range-ticks' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="ticksFont"
previewText="100">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="ticksColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="showTickMarks">
{{ 'widgets.slider.tick-marks' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tickMarksCount" type="number" min="2"
step="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-color-input asBoxInput
formControlName="tickMarksColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.slider.colors' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.slider.main</div>
<tb-color-input asBoxInput
formControlName="mainColor">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.slider.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.rpc-state.disabled-state' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.slider.main</div>
<tb-color-input asBoxInput
formControlName="mainColorDisabled">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.slider.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColorDisabled">
</tb-color-input>
</div>
</div>
</div>
<div *ngIf="sliderWidgetConfigForm.get('layout').value === sliderLayout.extended"
class="tb-form-row column-xs">
<div class="fixed-title-width">
{{ 'widgets.slider.left-icon' | translate }}
</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="leftIconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="leftIconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
[color]="sliderWidgetConfigForm.get('leftIconColor').value"
formControlName="leftIcon">
</tb-material-icon-select>
<tb-color-input asBoxInput
formControlName="leftIconColor">
</tb-color-input>
</div>
</div>
<div *ngIf="sliderWidgetConfigForm.get('layout').value === sliderLayout.extended"
class="tb-form-row column-xs">
<div class="fixed-title-width">
{{ 'widgets.slider.right-icon' | translate }}
</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="rightIconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="rightIconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
[color]="sliderWidgetConfigForm.get('rightIconColor').value"
formControlName="rightIcon">
</tb-material-icon-select>
<tb-color-input asBoxInput
formControlName="rightIconColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-border-radius' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<tb-widget-actions-panel
formControlName="actions">
</tb-widget-actions-panel>
</ng-container>

309
ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.ts

@ -0,0 +1,309 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { TargetDevice, WidgetConfig, } from '@shared/models/widget.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { formatValue, isUndefined } from '@core/utils';
import { ValueType } from '@shared/models/constants';
import {
SliderLayout,
sliderLayoutImages,
sliderLayouts,
sliderLayoutTranslations,
sliderWidgetDefaultSettings,
SliderWidgetSettings
} from '@home/components/widget/lib/rpc/slider-widget.models';
import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings.models';
@Component({
selector: 'tb-slider-basic-config',
templateUrl: './slider-basic-config.component.html',
styleUrls: ['../basic-config.scss']
})
export class SliderBasicConfigComponent extends BasicWidgetConfigComponent {
get targetDevice(): TargetDevice {
return this.sliderWidgetConfigForm.get('targetDevice').value;
}
sliderLayout = SliderLayout;
sliderLayouts = sliderLayouts;
sliderLayoutTranslationMap = sliderLayoutTranslations;
sliderLayoutImageMap = sliderLayoutImages;
valueType = ValueType;
sliderWidgetConfigForm: UntypedFormGroup;
valuePreviewFn = this._valuePreviewFn.bind(this);
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.sliderWidgetConfigForm;
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: SliderWidgetSettings = {...sliderWidgetDefaultSettings, ...(configData.config.settings || {})};
const iconSize = resolveCssSize(configData.config.iconSize);
this.sliderWidgetConfigForm = this.fb.group({
targetDevice: [configData.config.targetDevice, []],
initialState: [settings.initialState, []],
valueChange: [settings.valueChange, []],
disabledState: [settings.disabledState, []],
layout: [settings.layout, []],
autoScale: [settings.autoScale, []],
showTitle: [configData.config.showTitle, []],
title: [configData.config.title, []],
titleFont: [configData.config.titleFont, []],
titleColor: [configData.config.titleColor, []],
showIcon: [configData.config.showTitleIcon, []],
iconSize: [iconSize[0], [Validators.min(0)]],
iconSizeUnit: [iconSize[1], []],
icon: [configData.config.titleIcon, []],
iconColor: [configData.config.iconColor, []],
showValue: [settings.showValue, []],
valueUnits: [settings.valueUnits, []],
valueDecimals: [settings.valueDecimals, []],
valueFont: [settings.valueFont, []],
valueColor: [settings.valueColor, []],
tickMin: [settings.tickMin, []],
tickMax: [settings.tickMax, []],
showTicks: [settings.showTicks, []],
ticksFont: [settings.ticksFont, []],
ticksColor: [settings.ticksColor, []],
showTickMarks: [settings.showTickMarks, []],
tickMarksCount: [settings.tickMarksCount, [Validators.min(2)]],
tickMarksColor: [settings.tickMarksColor, []],
mainColor: [settings.mainColor, []],
backgroundColor: [settings.backgroundColor, []],
mainColorDisabled: [settings.mainColorDisabled, []],
backgroundColorDisabled: [settings.backgroundColorDisabled, []],
leftIconSize: [settings.leftIconSize, [Validators.min(0)]],
leftIconSizeUnit: [settings.leftIconSizeUnit, []],
leftIcon: [settings.leftIcon, []],
leftIconColor: [settings.leftIconColor, []],
rightIconSize: [settings.rightIconSize, [Validators.min(0)]],
rightIconSizeUnit: [settings.rightIconSizeUnit, []],
rightIcon: [settings.rightIcon, []],
rightIconColor: [settings.rightIconColor, []],
background: [settings.background, []],
cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []],
actions: [configData.config.actions || {}, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.targetDevice = config.targetDevice;
this.widgetConfig.config.showTitle = config.showTitle;
this.widgetConfig.config.title = config.title;
this.widgetConfig.config.titleFont = config.titleFont;
this.widgetConfig.config.titleColor = config.titleColor;
this.widgetConfig.config.showTitleIcon = config.showIcon;
this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit);
this.widgetConfig.config.titleIcon = config.icon;
this.widgetConfig.config.iconColor = config.iconColor;
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {};
this.widgetConfig.config.settings.initialState = config.initialState;
this.widgetConfig.config.settings.valueChange = config.valueChange;
this.widgetConfig.config.settings.disabledState = config.disabledState;
this.widgetConfig.config.settings.layout = config.layout;
this.widgetConfig.config.settings.autoScale = config.autoScale;
this.widgetConfig.config.settings.showValue = config.showValue;
this.widgetConfig.config.settings.valueUnits = config.valueUnits;
this.widgetConfig.config.settings.valueDecimals = config.valueDecimals;
this.widgetConfig.config.settings.valueFont = config.valueFont;
this.widgetConfig.config.settings.valueColor = config.valueColor;
this.widgetConfig.config.settings.tickMin = config.tickMin;
this.widgetConfig.config.settings.tickMax = config.tickMax;
this.widgetConfig.config.settings.showTicks = config.showTicks;
this.widgetConfig.config.settings.ticksFont = config.ticksFont;
this.widgetConfig.config.settings.ticksColor = config.ticksColor;
this.widgetConfig.config.settings.showTickMarks = config.showTickMarks;
this.widgetConfig.config.settings.tickMarksCount = config.tickMarksCount;
this.widgetConfig.config.settings.tickMarksColor = config.tickMarksColor;
this.widgetConfig.config.settings.mainColor = config.mainColor;
this.widgetConfig.config.settings.backgroundColor = config.backgroundColor;
this.widgetConfig.config.settings.mainColorDisabled = config.mainColorDisabled;
this.widgetConfig.config.settings.backgroundColorDisabled = config.backgroundColorDisabled;
this.widgetConfig.config.settings.leftIconSize = config.leftIconSize;
this.widgetConfig.config.settings.leftIconSizeUnit = config.leftIconSizeUnit;
this.widgetConfig.config.settings.leftIcon = config.leftIcon;
this.widgetConfig.config.settings.leftIconColor = config.leftIconColor;
this.widgetConfig.config.settings.rightIconSize = config.rightIconSize;
this.widgetConfig.config.settings.rightIconSizeUnit = config.rightIconSizeUnit;
this.widgetConfig.config.settings.rightIcon = config.rightIcon;
this.widgetConfig.config.settings.rightIconColor = config.rightIconColor;
this.widgetConfig.config.settings.background = config.background;
this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.actions = config.actions;
return this.widgetConfig;
}
protected validatorTriggers(): string[] {
return ['showTitle', 'showIcon', 'showValue', 'showTicks', 'showTickMarks', 'layout'];
}
protected updateValidators(emitEvent: boolean, trigger?: string) {
const showTitle: boolean = this.sliderWidgetConfigForm.get('showTitle').value;
const showIcon: boolean = this.sliderWidgetConfigForm.get('showIcon').value;
const showValue: boolean = this.sliderWidgetConfigForm.get('showValue').value;
const showTicks: boolean = this.sliderWidgetConfigForm.get('showTicks').value;
const showTickMarks: boolean = this.sliderWidgetConfigForm.get('showTickMarks').value;
const layout: SliderLayout = this.sliderWidgetConfigForm.get('layout').value;
const valueEnabled = layout !== SliderLayout.simplified;
const leftRightIconsEnabled = layout === SliderLayout.extended;
if (showTitle) {
this.sliderWidgetConfigForm.get('title').enable();
this.sliderWidgetConfigForm.get('titleFont').enable();
this.sliderWidgetConfigForm.get('titleColor').enable();
this.sliderWidgetConfigForm.get('showIcon').enable({emitEvent: false});
if (showIcon) {
this.sliderWidgetConfigForm.get('iconSize').enable();
this.sliderWidgetConfigForm.get('iconSizeUnit').enable();
this.sliderWidgetConfigForm.get('icon').enable();
this.sliderWidgetConfigForm.get('iconColor').enable();
} else {
this.sliderWidgetConfigForm.get('iconSize').disable();
this.sliderWidgetConfigForm.get('iconSizeUnit').disable();
this.sliderWidgetConfigForm.get('icon').disable();
this.sliderWidgetConfigForm.get('iconColor').disable();
}
} else {
this.sliderWidgetConfigForm.get('title').disable();
this.sliderWidgetConfigForm.get('titleFont').disable();
this.sliderWidgetConfigForm.get('titleColor').disable();
this.sliderWidgetConfigForm.get('showIcon').disable({emitEvent: false});
this.sliderWidgetConfigForm.get('iconSize').disable();
this.sliderWidgetConfigForm.get('iconSizeUnit').disable();
this.sliderWidgetConfigForm.get('icon').disable();
this.sliderWidgetConfigForm.get('iconColor').disable();
}
if (valueEnabled && showValue) {
this.sliderWidgetConfigForm.get('valueUnits').enable();
this.sliderWidgetConfigForm.get('valueDecimals').enable();
this.sliderWidgetConfigForm.get('valueFont').enable();
this.sliderWidgetConfigForm.get('valueColor').enable();
} else {
this.sliderWidgetConfigForm.get('valueUnits').disable();
this.sliderWidgetConfigForm.get('valueDecimals').disable();
this.sliderWidgetConfigForm.get('valueFont').disable();
this.sliderWidgetConfigForm.get('valueColor').disable();
}
if (showTicks) {
this.sliderWidgetConfigForm.get('ticksFont').enable();
this.sliderWidgetConfigForm.get('ticksColor').enable();
} else {
this.sliderWidgetConfigForm.get('ticksFont').disable();
this.sliderWidgetConfigForm.get('ticksColor').disable();
}
if (showTickMarks) {
this.sliderWidgetConfigForm.get('tickMarksCount').enable();
this.sliderWidgetConfigForm.get('tickMarksColor').enable();
} else {
this.sliderWidgetConfigForm.get('tickMarksCount').disable();
this.sliderWidgetConfigForm.get('tickMarksColor').disable();
}
if (leftRightIconsEnabled) {
this.sliderWidgetConfigForm.get('leftIconSize').enable();
this.sliderWidgetConfigForm.get('leftIconSizeUnit').enable();
this.sliderWidgetConfigForm.get('leftIcon').enable();
this.sliderWidgetConfigForm.get('leftIconColor').enable();
this.sliderWidgetConfigForm.get('rightIconSize').enable();
this.sliderWidgetConfigForm.get('rightIconSizeUnit').enable();
this.sliderWidgetConfigForm.get('rightIcon').enable();
this.sliderWidgetConfigForm.get('rightIconColor').enable();
} else {
this.sliderWidgetConfigForm.get('leftIconSize').disable();
this.sliderWidgetConfigForm.get('leftIconSizeUnit').disable();
this.sliderWidgetConfigForm.get('leftIcon').disable();
this.sliderWidgetConfigForm.get('leftIconColor').disable();
this.sliderWidgetConfigForm.get('rightIconSize').disable();
this.sliderWidgetConfigForm.get('rightIconSizeUnit').disable();
this.sliderWidgetConfigForm.get('rightIcon').disable();
this.sliderWidgetConfigForm.get('rightIconColor').disable();
}
}
private getCardButtons(config: WidgetConfig): string[] {
const buttons: string[] = [];
if (isUndefined(config.enableFullscreen) || config.enableFullscreen) {
buttons.push('fullscreen');
}
return buttons;
}
private setCardButtons(buttons: string[], config: WidgetConfig) {
config.enableFullscreen = buttons.includes('fullscreen');
}
private _valuePreviewFn(): string {
const units: string = this.sliderWidgetConfigForm.get('valueUnits').value;
const decimals: number = this.sliderWidgetConfigForm.get('valueDecimals').value;
return formatValue(48, decimals, units, false);
}
}

17
ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts

@ -62,8 +62,6 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A
loading$ = this.loadingSubject.asObservable().pipe(share());
error = '';
protected constructor(protected cd: ChangeDetectorRef) {
}
@ -91,14 +89,15 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A
ngOnDestroy() {
this.valueActions.forEach(v => v.destroy());
this.loadingSubject.complete();
this.loadingSubject.unsubscribe();
}
public onInit() {
}
public clearError() {
this.error = '';
this.cd.markForCheck();
this.ctx.hideToast(this.ctx.toastTargetId);
}
protected createValueGetter<V>(getValueSettings: GetValueSettings<V>,
@ -111,7 +110,8 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A
}
},
error: (err: any) => {
this.onError(err);
const message = parseError(this.ctx, err);
this.onError(message);
if (valueObserver?.error) {
valueObserver.error(err);
}
@ -130,8 +130,7 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A
}
private onError(error: string) {
this.error = error;
this.cd.markForCheck();
this.ctx.showErrorToast(error, 'bottom', 'center', this.ctx.toastTargetId, true);
}
protected updateValue<V>(valueSetter: ValueSetter<V>,
@ -293,6 +292,8 @@ export class ValueToDataConverter<V> {
constructor(protected settings: ValueToDataSettings) {
switch (settings.type) {
case ValueToDataType.VALUE:
break;
case ValueToDataType.CONSTANT:
this.constantValue = this.settings.constantValue;
break;
@ -310,6 +311,8 @@ export class ValueToDataConverter<V> {
valueToData(value: V): any {
switch (this.settings.type) {
case ValueToDataType.VALUE:
return value;
case ValueToDataType.CONSTANT:
return this.constantValue;
case ValueToDataType.FUNCTION:

31
ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.scss

@ -19,34 +19,3 @@
left: 0;
right: 0;
}
.tb-action-widget-error-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1;
.tb-action-widget-error-panel {
display: flex;
padding: 4px 4px 4px 12px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 4px;
background-color: #fff2f3;
box-shadow: -2px 2px 4px 0px rgba(0,0,0,0.2);
.tb-action-widget-error-text {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
color: rgba(209, 39, 48, 1);
}
.tb-action-widget-error-clear {
color: rgba(209, 39, 48, 1);
}
}
}

6
ui-ngx/src/app/modules/home/components/widget/lib/button/action-button-widget.component.html

@ -27,10 +27,4 @@
[ctx]="ctx"
(clicked)="onClick($event)">
</tb-widget-button>
<div *ngIf="error" class="tb-action-widget-error-container">
<div class="tb-action-widget-error-panel">
<div class="tb-action-widget-error-text" [innerHTML]="error | safe: 'html'"></div>
<button class="tb-action-widget-error-clear tb-mat-20" mat-icon-button (click)="clearError()"><mat-icon>close</mat-icon></button>
</div>
</div>
</div>

6
ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.html

@ -27,10 +27,4 @@
(clicked)="onClick($event)">
</tb-widget-button>
<mat-progress-bar class="tb-action-widget-progress" style="height: 4px;" color="accent" mode="indeterminate" *ngIf="loading$ | async"></mat-progress-bar>
<div *ngIf="error" class="tb-action-widget-error-container">
<div class="tb-action-widget-error-panel">
<div class="tb-action-widget-error-text" [innerHTML]="error | safe: 'html'"></div>
<button class="tb-action-widget-error-clear tb-mat-20" mat-icon-button (click)="clearError()"><mat-icon>close</mat-icon></button>
</div>
</div>
</div>

2
ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts

@ -241,8 +241,8 @@ export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit
} else {
aggValue.value = 'N/A';
}
aggValue.color.update(value);
const numeric = formatNumberValue(value, (aggValue.key.decimals || this.ctx.decimals));
aggValue.color.update(numeric);
if (aggValue.showArrow && isDefined(numeric)) {
aggValue.upArrow = numeric > 0;
aggValue.downArrow = numeric < 0;

8
ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.ts

@ -127,11 +127,11 @@ export class GatewayConfigurationComponent implements OnInit {
type: [StorageTypes.MEMORY, [Validators.required]],
read_records_count: [100, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/), Validators.required, Validators.pattern(/^[^.\s]+$/)]],
max_records_count: [100000, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/), Validators.required, Validators.pattern(/^[^.\s]+$/)]],
data_folder_path: ['./data/', [Validators.pattern(/^[^\s]+$/)]],
data_folder_path: ['./data/', [Validators.required]],
max_file_count: [10, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
max_read_records_count: [10, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
max_records_per_file: [10000, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
data_file_path: ['./data/data.db', [Validators.pattern(/^[^\s]+$/)]],
data_file_path: ['./data/data.db', [Validators.required]],
messages_ttl_check_in_hours: [1, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
messages_ttl_in_days: [7, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
@ -240,7 +240,7 @@ export class GatewayConfigurationComponent implements OnInit {
storageGroup.get('read_records_count').updateValueAndValidity({emitEvent: false});
storageGroup.get('max_records_count').updateValueAndValidity({emitEvent: false});
} else if (type === StorageTypes.FILE) {
storageGroup.get('data_folder_path').addValidators([Validators.required, Validators.pattern(/^[^.\s]+$/)]);
storageGroup.get('data_folder_path').addValidators([Validators.required]);
storageGroup.get('max_file_count').addValidators(
[Validators.min(1), Validators.pattern(/^-?[0-9]+$/), Validators.required]);
storageGroup.get('max_read_records_count').addValidators(
@ -252,7 +252,7 @@ export class GatewayConfigurationComponent implements OnInit {
storageGroup.get('max_read_records_count').updateValueAndValidity({emitEvent: false});
storageGroup.get('max_records_per_file').updateValueAndValidity({emitEvent: false});
} else if (type === StorageTypes.SQLITE) {
storageGroup.get('data_file_path').addValidators([Validators.required, Validators.pattern(/^[^.\s]+$/)]);
storageGroup.get('data_file_path').addValidators([Validators.required]);
storageGroup.get('messages_ttl_check_in_hours').addValidators(
[Validators.min(1), Validators.pattern(/^-?[0-9]+$/), Validators.required]);
storageGroup.get('messages_ttl_in_days').addValidators(

2
ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html

@ -80,7 +80,7 @@
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef
[ngStyle.gt-md]="{ minWidth: '144px', maxWidth: '144px', width: '144px'}">
[ngStyle.gt-md]="{ minWidth: '144px', maxWidth: '144px', width: '144px', textAlign: 'center'}">
{{ 'gateway.connectors-table-actions' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let attribute"

7
ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.scss

@ -30,6 +30,13 @@
flex-direction: column;
}
& > section:not(.table-section) {
max-width: unset;
@media #{$mat-gt-md} {
max-width: 50%;
}
}
.table-section {
min-height: 35vh;
overflow: hidden;

3
ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts

@ -366,7 +366,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie
type: 'success',
duration: 1000,
verticalPosition: 'top',
horizontalPosition: 'right',
horizontalPosition: 'left',
target: 'dashboardRoot',
forceDismiss: true
}));
@ -381,6 +381,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie
if ($event) {
$event.stopPropagation();
}
this.initialConnector = attribute.value;
const title = `Delete connector ${attribute.key}?`;
const content = `All connector data will be deleted.`;
this.dialogService.confirm(title, content, 'Cancel', 'Delete').subscribe(result => {

63
ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts

@ -106,6 +106,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
private volume: number;
private tooltipContent: string;
private widgetUnits: string;
private volumeUnits: string;
private capacityUnits = Object.values(CapacityUnits);
@ -128,7 +129,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
this.getData().subscribe(data => {
if (data) {
const { svg, volume, units } = data;
const { svg, volume, units, volumeUnits } = data;
if (svg && isNotEmptyStr(svg) && this.liquidLevelContent.nativeElement) {
const jQueryContainerElement = $(this.liquidLevelContent.nativeElement);
jQueryContainerElement.html(svg);
@ -145,6 +146,10 @@ export class LiquidLevelWidgetComponent implements OnInit {
this.volume = Number(volume);
}
if (volumeUnits) {
this.volumeUnits = volumeUnits;
}
if (units) {
this.widgetUnits = units;
}
@ -164,7 +169,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat);
}
private getData(): Observable<{ svg: string; volume: number; units: string }> {
private getData(): Observable<{ svg: string; volume: number; units: string; volumeUnits: string}> {
if (this.ctx.datasources?.length) {
const entityId: EntityId = {
entityType: this.ctx.datasources[0].entityType,
@ -308,7 +313,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
.pipe(map(attributes => {
const shape = extractValue<Shapes>(attributes, this.settings.shapeAttributeName);
if (!shape || !svgMapping.has(shape)) {
this.createdErrorMgs(this.settings.shapeAttributeName, isUndefinedOrNull(shape) || isEmptyStr(shape));
this.createdErrorMsg(this.settings.shapeAttributeName, isUndefinedOrNull(shape) || isEmptyStr(shape));
return this.settings.selectedShape;
}
return shape;
@ -318,12 +323,15 @@ export class LiquidLevelWidgetComponent implements OnInit {
return of(this.settings.selectedShape);
}
private getTankersParams(entityId: EntityId): Observable<{ volume: number; units: string }> {
private getTankersParams(entityId: EntityId): Observable<{ volume: number; units: string; volumeUnits: string }> {
const isVolumeStatic = this.settings.layout !== LevelCardLayout.absolute
&& this.settings.datasourceUnits === CapacityUnits.percent
|| this.settings.volumeSource === LiquidWidgetDataSourceType.static;
const isUnitStatic = this.settings.layout !== LevelCardLayout.absolute ||
this.settings.widgetUnitsSource === LiquidWidgetDataSourceType.static;
const isVolumeUnitStatic = this.settings.layout !== LevelCardLayout.absolute
&& this.settings.datasourceUnits === CapacityUnits.percent
|| this.settings.volumeUnitsSource === LiquidWidgetDataSourceType.static;
const attributeKeys: string[] = [];
@ -335,20 +343,29 @@ export class LiquidLevelWidgetComponent implements OnInit {
attributeKeys.push(this.settings.widgetUnitsAttributeName);
}
if (!isVolumeUnitStatic) {
attributeKeys.push(this.settings.volumeUnitsAttributeName);
}
if (!attributeKeys.length || entityId.id === NULL_UUID) {
return of({
volume: this.settings.volumeConstant,
volumeUnits: this.settings.volumeUnits,
units: this.settings.units
});
}
return this.ctx.attributeService.getEntityAttributes(entityId, null, attributeKeys).pipe(
map(attributes => {
let volume = isVolumeStatic ? this.settings.volumeConstant : extractValue<number>(attributes, this.settings.volumeAttributeName);
let units = isUnitStatic ? this.settings.units : extractValue<string>(attributes, this.settings.widgetUnitsAttributeName);
let volume = isVolumeStatic ? this.settings.volumeConstant :
extractValue<number>(attributes, this.settings.volumeAttributeName);
let volumeUnits = isVolumeUnitStatic ? this.settings.volumeUnits :
extractValue<string>(attributes, this.settings.volumeUnitsAttributeName);
let units = isUnitStatic ? this.settings.units :
extractValue<string>(attributes, this.settings.widgetUnitsAttributeName);
if (!isVolumeStatic && (!volume || !isNumeric(volume) || volume < 0.1)) {
this.createdErrorMgs(this.settings.volumeAttributeName, isUndefinedOrNull(volume) || isEmptyStr(volume));
this.createdErrorMsg(this.settings.volumeAttributeName, isUndefinedOrNull(volume) || isEmptyStr(volume));
volume = this.settings.volumeConstant;
}
@ -358,20 +375,33 @@ export class LiquidLevelWidgetComponent implements OnInit {
units = this.capacityUnits.find(unit => unit.normalize() === normalizeUnits);
}
if (isUndefinedOrNull(units) || !isNotEmptyStr(units)) {
this.createdErrorMgs(this.settings.widgetUnitsAttributeName, isUndefinedOrNull(units) || isEmptyStr(units));
this.createdErrorMsg(this.settings.widgetUnitsAttributeName, isUndefinedOrNull(units) || isEmptyStr(units));
units = this.settings.units;
}
}
if (!isVolumeUnitStatic) {
if (isNotEmptyStr(volumeUnits)) {
const normalizeUnits = volumeUnits.normalize().trim();
volumeUnits = this.capacityUnits.find(unit => unit.normalize() === normalizeUnits);
}
if (isUndefinedOrNull(volumeUnits) || !isNotEmptyStr(volumeUnits)) {
this.createdErrorMsg(this.settings.widgetUnitsAttributeName,
isUndefinedOrNull(volumeUnits) || isEmptyStr(volumeUnits));
volumeUnits = this.settings.volumeUnits;
}
}
return {
volume,
volumeUnits,
units
};
})
);
}
private createdErrorMgs(attributeName: string, isEmpty = false) {
private createdErrorMsg(attributeName: string, isEmpty = false) {
if (isEmpty) {
this.errorsMsg.push(this.translate.instant('widgets.liquid-level-card.attribute-key-not-set', {attributeName}));
} else {
@ -474,9 +504,14 @@ export class LiquidLevelWidgetComponent implements OnInit {
}
if (this.settings.layout === LevelCardLayout.absolute) {
const volumeInLiters: number = convertLiters(this.volume, this.settings.volumeUnits as CapacityUnits, ConversionType.to);
const volume = convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from)
.toFixed(this.settings.decimals || 0);
let volume: number | string;
if (this.widgetUnits !== CapacityUnits.percent) {
const volumeInLiters: number = convertLiters(this.volume, this.volumeUnits as CapacityUnits, ConversionType.to);
volume = convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from)
.toFixed(this.settings.decimals || 0);
} else {
volume = this.volume.toFixed(this.settings.decimals || 0);
}
const volumeTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.volumeFont),
color: this.settings.volumeColor});
@ -553,7 +588,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
private convertInputData(value: any): number {
if (this.settings.datasourceUnits !== CapacityUnits.percent) {
return (convertLiters(Number(value), this.settings.datasourceUnits, ConversionType.to) /
convertLiters(this.volume, this.settings.volumeUnits, ConversionType.to)) * 100;
convertLiters(this.volume, this.volumeUnits as CapacityUnits, ConversionType.to)) * 100;
}
return Number(value);
@ -561,7 +596,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
private convertOutputData(value: number): number {
if (this.widgetUnits !== CapacityUnits.percent) {
return convertLiters(this.volume * (value / 100), this.settings.volumeUnits, ConversionType.to);
return convertLiters(this.volume * (value / 100), this.volumeUnits as CapacityUnits, ConversionType.to);
}
return value;

31
ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.models.ts

@ -56,6 +56,8 @@ export interface LevelCardWidgetSettings extends WidgetConfig {
volumeSource: LiquidWidgetDataSourceType;
volumeConstant: number;
volumeAttributeName: string;
volumeUnitsSource: LiquidWidgetDataSourceType;
volumeUnitsAttributeName: string;
volumeUnits: CapacityUnits;
volumeFont: Font;
volumeColor: string;
@ -257,8 +259,10 @@ export const levelCardDefaultSettings: LevelCardWidgetSettings = {
iconColor: '#5469FF',
volumeSource: LiquidWidgetDataSourceType.static,
volumeConstant: 500,
volumeUnits: CapacityUnits.liters,
volumeAttributeName: 'volume',
volumeUnitsSource: LiquidWidgetDataSourceType.static,
volumeUnitsAttributeName: 'volumeUnits',
volumeUnits: CapacityUnits.liters,
volumeFont: {
family: 'Roboto',
size: 14,
@ -375,9 +379,7 @@ export const convertLiters = (value: number, units: CapacityUnits, conversionTyp
return conversionType === ConversionType.to ? value / factor : value * factor;
};
export const extractValue = <T>(attributes: Array<AttributeData>, attributeName: string): T | undefined => {
return attributes.find(attr => attr.key === attributeName)?.value;
};
export const extractValue = <T>(attributes: Array<AttributeData>, attributeName: string): T | undefined => attributes.find(attr => attr.key === attributeName)?.value;
export const valueContainerStyleDefaults = cssTextFromInlineStyle({
width: '100%',
@ -494,6 +496,7 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
const datasourceUnits: string = formGroup.get('datasourceUnits').value;
const layout: LevelCardLayout = formGroup.get('layout').value;
const volumeSource: LiquidWidgetDataSourceType = formGroup.get('volumeSource').value;
const volumeUnitsSource: LiquidWidgetDataSourceType = formGroup.get('volumeUnitsSource').value;
const widgetUnitsSource: LiquidWidgetDataSourceType = formGroup.get('widgetUnitsSource').value;
const showTooltipLevel: boolean = formGroup.get('showTooltipLevel').value;
const showTooltipDate: boolean = formGroup.get('showTooltipDate').value;
@ -517,7 +520,7 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
if (datasourceUnits !== CapacityUnits.percent) {
formGroup.get('volumeSource').enable({emitEvent: false});
formGroup.get('volumeUnits').enable({emitEvent: false});
formGroup.get('volumeUnitsSource').enable({emitEvent: false});
if (volumeSource === LiquidWidgetDataSourceType.static) {
formGroup.get('volumeConstant').enable({emitEvent: false});
formGroup.get('volumeAttributeName').disable({emitEvent: false});
@ -525,11 +528,20 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
formGroup.get('volumeConstant').disable({emitEvent: false});
formGroup.get('volumeAttributeName').enable({emitEvent: false});
}
if (volumeUnitsSource === LiquidWidgetDataSourceType.static) {
formGroup.get('volumeUnits').enable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').disable({emitEvent: false});
} else {
formGroup.get('volumeUnits').disable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').enable({emitEvent: false});
}
} else {
formGroup.get('volumeSource').disable({emitEvent: false});
formGroup.get('volumeConstant').disable({emitEvent: false});
formGroup.get('volumeAttributeName').disable({emitEvent: false});
formGroup.get('volumeUnitsSource').disable({emitEvent: false});
formGroup.get('volumeUnits').disable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').disable({emitEvent: false});
}
if (layout === LevelCardLayout.simple) {
@ -557,7 +569,7 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
}
formGroup.get('volumeSource').enable({emitEvent: false});
formGroup.get('volumeUnits').enable({emitEvent: false});
formGroup.get('volumeUnitsSource').enable({emitEvent: false});
if (volumeSource === LiquidWidgetDataSourceType.static) {
formGroup.get('volumeConstant').enable({emitEvent: false});
formGroup.get('volumeAttributeName').disable({emitEvent: false});
@ -565,6 +577,13 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
formGroup.get('volumeConstant').disable({emitEvent: false});
formGroup.get('volumeAttributeName').enable({emitEvent: false});
}
if (volumeUnitsSource === LiquidWidgetDataSourceType.static) {
formGroup.get('volumeUnits').enable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').disable({emitEvent: false});
} else {
formGroup.get('volumeUnits').disable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').enable({emitEvent: false});
}
if (formGroup.get('decimals')) {
formGroup.get('decimals').enable({emitEvent: false});

23
ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts

@ -32,7 +32,7 @@ import {
WidgetUnitedMapSettings
} from './map-models';
import { Marker } from './markers';
import { map, Observable, of, switchMap } from 'rxjs';
import { map, Observable, of } from 'rxjs';
import { Polyline } from './polyline';
import { Polygon } from './polygon';
import { Circle } from './circle';
@ -64,6 +64,7 @@ import { MatDialog } from '@angular/material/dialog';
import { FormattedData, ReplaceInfo } from '@shared/models/widget.models';
import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance;
import { ImagePipe } from '@shared/pipe/image.pipe';
import { take, tap } from 'rxjs/operators';
export default abstract class LeafletMap {
@ -940,7 +941,12 @@ export default abstract class LeafletMap {
this.markersData = markersData;
if (this.options.useClusterMarkers) {
if (createdMarkers.length) {
this.markersCluster.addLayers(createdMarkers.map(marker => marker.leafletMarker));
createdMarkers.forEach((marker) => {
marker.createMarkerIconSubject.pipe(
tap(() => this.markersCluster.addLayer(marker.leafletMarker)),
take(1)
).subscribe();
});
}
if (updatedMarkers.length) {
this.markersCluster.refreshClusters(updatedMarkers.map(marker => marker.leafletMarker));
@ -971,10 +977,15 @@ export default abstract class LeafletMap {
}
this.markers.set(key, newMarker);
if (!this.options.useClusterMarkers) {
this.map.addLayer(newMarker.leafletMarker);
if (this.map.pm.globalDragModeEnabled() && newMarker.leafletMarker.pm) {
newMarker.leafletMarker.pm.enableLayerDrag();
}
newMarker.createMarkerIconSubject.pipe(
tap(() => {
this.map.addLayer(newMarker.leafletMarker);
if (this.map.pm.globalDragModeEnabled() && newMarker.leafletMarker.pm) {
newMarker.leafletMarker.pm.enableLayerDrag();
}
}),
take(1)
).subscribe();
}
return newMarker;
}

3
ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts

@ -23,6 +23,7 @@ import { fillDataPattern, isDefined, isDefinedAndNotNull, processDataPattern, sa
import LeafletMap from './leaflet-map';
import { FormattedData } from '@shared/models/widget.models';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { ReplaySubject } from 'rxjs';
export class Marker {
@ -33,6 +34,7 @@ export class Marker {
tooltipOffset: L.LatLngTuple;
markerOffset: L.LatLngTuple;
tooltip: L.Popup;
createMarkerIconSubject = new ReplaySubject<MarkerIconInfo>();
constructor(private map: LeafletMap,
private location: L.LatLng,
@ -148,6 +150,7 @@ export class Marker {
this.labelOffset = [0, -iconInfo.size[1] * this.markerOffset[1] + 10];
}
this.updateMarkerLabel(settings);
this.createMarkerIconSubject.next(iconInfo);
});
}

26
ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.html

@ -0,0 +1,26 @@
<!--
Copyright © 2016-2024 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.
-->
<div class="tb-power-button-panel" [style]="backgroundStyle$ | async">
<div class="tb-power-button-overlay" [style]="overlayStyle"></div>
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
<div class="tb-power-button-content">
<div #powerButtonShape class="tb-power-button-shape" [class.tb-power-button-pointer]="!disabled && (loading$ | async) === false">
</div>
</div>
<mat-progress-bar class="tb-action-widget-progress" style="height: 4px;" color="accent" mode="indeterminate" *ngIf="loading$ | async"></mat-progress-bar>
</div>

64
ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.scss

@ -0,0 +1,64 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.tb-power-button-panel {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 24px 24px 24px;
> div:not(.tb-power-button-overlay) {
z-index: 1;
}
.tb-power-button-overlay {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px;
right: 12px;
}
div.tb-widget-title {
padding: 0;
}
.tb-power-button-content {
flex: 1;
min-width: 0;
min-height: 0;
.tb-power-button-shape {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
svg {
.tb-small-shadow {
filter: drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.2));
}
.tb-shadow {
filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.15));
}
}
&.tb-power-button-pointer {
svg {
.tb-hover-circle {
cursor: pointer;
}
}
}
}
}
}

203
ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.ts

@ -0,0 +1,203 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
OnDestroy,
OnInit,
Renderer2,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget/lib/action/action-widget.models';
import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models';
import { Observable } from 'rxjs';
import { ResizeObserver } from '@juggle/resize-observer';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
import { ValueType } from '@shared/models/constants';
import {
powerButtonDefaultSettings,
PowerButtonShape,
powerButtonShapeSize,
PowerButtonWidgetSettings
} from '@home/components/widget/lib/rpc/power-button-widget.models';
import { SVG, Svg } from '@svgdotjs/svg.js';
@Component({
selector: 'tb-power-button-widget',
templateUrl: './power-button-widget.component.html',
styleUrls: ['../action/action-widget.scss', './power-button-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class PowerButtonWidgetComponent extends
BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('powerButtonShape', {static: false})
powerButtonShape: ElementRef<HTMLElement>;
settings: PowerButtonWidgetSettings;
backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {};
value = false;
disabled = false;
private shapeResize$: ResizeObserver;
private drawSvgShapePending = false;
private svgShape: Svg;
private powerButtonSvgShape: PowerButtonShape;
private disabledState = false;
private onValueSetter: ValueSetter<boolean>;
private offValueSetter: ValueSetter<boolean>;
constructor(protected imagePipe: ImagePipe,
protected sanitizer: DomSanitizer,
private renderer: Renderer2,
protected cd: ChangeDetectorRef) {
super(cd);
}
ngOnInit(): void {
super.ngOnInit();
this.settings = {...powerButtonDefaultSettings, ...this.ctx.settings};
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
const getInitialStateSettings =
{...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.initial-state')};
this.createValueGetter(getInitialStateSettings, ValueType.BOOLEAN, {
next: (value) => this.onValue(value)
});
const disabledStateSettings =
{...this.settings.disabledState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.disabled-state')};
this.createValueGetter(disabledStateSettings, ValueType.BOOLEAN, {
next: (value) => this.onDisabled(value)
});
const onUpdateStateSettings = {...this.settings.onUpdateState,
actionLabel: this.ctx.translate.instant('widgets.power-button.power-on')};
this.onValueSetter = this.createValueSetter(onUpdateStateSettings);
const offUpdateStateSettings = {...this.settings.offUpdateState,
actionLabel: this.ctx.translate.instant('widgets.power-button.power-off')};
this.offValueSetter = this.createValueSetter(offUpdateStateSettings);
this.loading$.subscribe((loading) => {
this.updateDisabledState(loading || this.disabled);
this.cd.markForCheck();
});
}
ngAfterViewInit(): void {
if (this.drawSvgShapePending) {
this.drawSvg();
}
super.ngAfterViewInit();
}
ngOnDestroy() {
if (this.shapeResize$) {
this.shapeResize$.disconnect();
}
super.ngOnDestroy();
}
public onInit() {
super.onInit();
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
if (this.powerButtonShape) {
this.drawSvg();
} else {
this.drawSvgShapePending = true;
}
this.cd.detectChanges();
}
private onValue(value: boolean): void {
const newValue = !!value;
if (this.value !== newValue) {
this.value = newValue;
this.powerButtonSvgShape?.setValue(this.value);
this.cd.markForCheck();
}
}
private onDisabled(value: boolean): void {
const newDisabled = !!value;
if (this.disabled !== newDisabled) {
this.disabled = newDisabled;
this.updateDisabledState(this.disabled);
this.cd.markForCheck();
}
}
private onClick() {
if (!this.ctx.isEdit && !this.ctx.isPreview && !this.disabledState) {
this.onValue(!this.value);
const targetValue = this.value;
const targetSetter = targetValue ? this.onValueSetter : this.offValueSetter;
this.powerButtonSvgShape?.setPressed(true);
this.updateValue(targetSetter, targetValue, {
next: () => {
this.powerButtonSvgShape?.setPressed(false);
this.onValue(targetValue);
},
error: () => {
this.powerButtonSvgShape?.setPressed(false);
this.onValue(!targetValue);
}
});
}
}
private drawSvg() {
this.svgShape = SVG().addTo(this.powerButtonShape.nativeElement).size(powerButtonShapeSize, powerButtonShapeSize);
this.renderer.setStyle(this.svgShape.node, 'overflow', 'visible');
this.renderer.setStyle(this.svgShape.node, 'user-select', 'none');
this.powerButtonSvgShape = PowerButtonShape.fromSettings(this.ctx, this.svgShape,
this.settings, this.value, this.disabledState, () => this.onClick());
this.shapeResize$ = new ResizeObserver(() => {
this.onResize();
});
this.shapeResize$.observe(this.powerButtonShape.nativeElement);
this.onResize();
}
private updateDisabledState(disabled: boolean) {
this.disabledState = disabled;
this.powerButtonSvgShape?.setDisabled(this.disabledState);
}
private onResize() {
const shapeWidth = this.powerButtonShape.nativeElement.getBoundingClientRect().width;
const shapeHeight = this.powerButtonShape.nativeElement.getBoundingClientRect().height;
const size = Math.min(shapeWidth, shapeHeight);
const scale = size / powerButtonShapeSize;
this.renderer.setStyle(this.svgShape.node, 'transform', `scale(${scale})`);
}
}

953
ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts

@ -0,0 +1,953 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import {
DataToValueType,
GetValueAction,
GetValueSettings,
SetValueAction,
SetValueSettings,
ValueToDataType
} from '@shared/models/action-widget-settings.models';
import { Circle, Effect, Element, G, Gradient, Runner, Svg, Text, Timeline } from '@svgdotjs/svg.js';
import '@svgdotjs/svg.filter.js';
import tinycolor from 'tinycolor2';
import { WidgetContext } from '@home/models/widget-component.models';
export enum PowerButtonLayout {
default = 'default',
simplified = 'simplified',
outlined = 'outlined',
default_volume = 'default_volume',
simplified_volume = 'simplified_volume',
outlined_volume = 'outlined_volume'
}
export const powerButtonLayouts = Object.keys(PowerButtonLayout) as PowerButtonLayout[];
export const powerButtonLayoutTranslations = new Map<PowerButtonLayout, string>(
[
[PowerButtonLayout.default, 'widgets.power-button.layout-default'],
[PowerButtonLayout.simplified, 'widgets.power-button.layout-simplified'],
[PowerButtonLayout.outlined, 'widgets.power-button.layout-outlined'],
[PowerButtonLayout.default_volume, 'widgets.power-button.layout-default-volume'],
[PowerButtonLayout.simplified_volume, 'widgets.power-button.layout-simplified-volume'],
[PowerButtonLayout.outlined_volume, 'widgets.power-button.layout-outlined-volume']
]
);
export const powerButtonLayoutImages = new Map<PowerButtonLayout, string>(
[
[PowerButtonLayout.default, 'assets/widget/power-button/default-layout.svg'],
[PowerButtonLayout.simplified, 'assets/widget/power-button/simplified-layout.svg'],
[PowerButtonLayout.outlined, 'assets/widget/power-button/outlined-layout.svg'],
[PowerButtonLayout.default_volume, 'assets/widget/power-button/default-volume-layout.svg'],
[PowerButtonLayout.simplified_volume, 'assets/widget/power-button/simplified-volume-layout.svg'],
[PowerButtonLayout.outlined_volume, 'assets/widget/power-button/outlined-volume-layout.svg']
]
);
export interface PowerButtonWidgetSettings {
initialState: GetValueSettings<boolean>;
disabledState: GetValueSettings<boolean>;
onUpdateState: SetValueSettings;
offUpdateState: SetValueSettings;
layout: PowerButtonLayout;
mainColorOn: string;
backgroundColorOn: string;
mainColorOff: string;
backgroundColorOff: string;
mainColorDisabled: string;
backgroundColorDisabled: string;
background: BackgroundSettings;
}
export const powerButtonDefaultSettings: PowerButtonWidgetSettings = {
initialState: {
action: GetValueAction.EXECUTE_RPC,
defaultValue: false,
executeRpc: {
method: 'getState',
requestTimeout: 5000,
requestPersistent: false,
persistentPollingInterval: 1000
},
getAttribute: {
key: 'state',
scope: null
},
getTimeSeries: {
key: 'state'
},
dataToValue: {
type: DataToValueType.NONE,
compareToValue: true,
dataToValueFunction: '/* Should return boolean value */\nreturn data;'
}
},
disabledState: {
action: GetValueAction.DO_NOTHING,
defaultValue: false,
getAttribute: {
key: 'state',
scope: null
},
getTimeSeries: {
key: 'state'
},
dataToValue: {
type: DataToValueType.NONE,
compareToValue: true,
dataToValueFunction: '/* Should return boolean value */\nreturn data;'
}
},
onUpdateState: {
action: SetValueAction.EXECUTE_RPC,
executeRpc: {
method: 'setState',
requestTimeout: 5000,
requestPersistent: false,
persistentPollingInterval: 1000
},
setAttribute: {
key: 'state',
scope: AttributeScope.SHARED_SCOPE
},
putTimeSeries: {
key: 'state'
},
valueToData: {
type: ValueToDataType.CONSTANT,
constantValue: true,
valueToDataFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;'
}
},
offUpdateState: {
action: SetValueAction.EXECUTE_RPC,
executeRpc: {
method: 'setState',
requestTimeout: 5000,
requestPersistent: false,
persistentPollingInterval: 1000
},
setAttribute: {
key: 'state',
scope: AttributeScope.SHARED_SCOPE
},
putTimeSeries: {
key: 'state'
},
valueToData: {
type: ValueToDataType.CONSTANT,
constantValue: false,
valueToDataFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */ \n return value;'
}
},
layout: PowerButtonLayout.default,
mainColorOn: '#3F52DD',
backgroundColorOn: '#FFFFFF',
mainColorOff: '#A2A2A2',
backgroundColorOff: '#FFFFFF',
mainColorDisabled: 'rgba(0,0,0,0.12)',
backgroundColorDisabled: '#FFFFFF',
background: {
type: BackgroundType.color,
color: '#fff',
overlay: {
enabled: false,
color: 'rgba(255,255,255,0.72)',
blur: 3
}
}
};
interface PowerButtonColor {
hex: string;
opacity: number;
}
type PowerButtonState = 'on' | 'off' | 'disabled';
interface PowerButtonColorState {
mainColor: PowerButtonColor;
backgroundColor: PowerButtonColor;
}
type PowerButtonShapeColors = Record<PowerButtonState, PowerButtonColorState>;
const createPowerButtonShapeColors = (settings: PowerButtonWidgetSettings): PowerButtonShapeColors => {
const mainColorOn = tinycolor(settings.mainColorOn);
const backgroundColorOn = tinycolor(settings.backgroundColorOn);
const mainColorOff = tinycolor(settings.mainColorOff);
const backgroundColorOff = tinycolor(settings.backgroundColorOff);
const mainColorDisabled = tinycolor(settings.mainColorDisabled);
const backgroundColorDisabled = tinycolor(settings.backgroundColorDisabled);
return {
on: {
mainColor: {hex: mainColorOn.toHexString(), opacity: mainColorOn.getAlpha()},
backgroundColor: {hex: backgroundColorOn.toHexString(), opacity: backgroundColorOn.getAlpha()},
},
off: {
mainColor: {hex: mainColorOff.toHexString(), opacity: mainColorOff.getAlpha()},
backgroundColor: {hex: backgroundColorOff.toHexString(), opacity: backgroundColorOff.getAlpha()},
},
disabled: {
mainColor: {hex: mainColorDisabled.toHexString(), opacity: mainColorDisabled.getAlpha()},
backgroundColor: {hex: backgroundColorDisabled.toHexString(), opacity: backgroundColorDisabled.getAlpha()},
}
};
};
export const powerButtonShapeSize = 110;
const cx = powerButtonShapeSize / 2;
const cy = powerButtonShapeSize / 2;
const powerButtonAnimation = (element: Element): Runner => element.animate(200, 0, 'now');
export abstract class PowerButtonShape {
static fromSettings(ctx: WidgetContext,
svgShape: Svg,
settings: PowerButtonWidgetSettings,
value: boolean,
disabled: boolean,
onClick: () => void): PowerButtonShape {
switch (settings.layout) {
case PowerButtonLayout.default:
return new DefaultPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick);
case PowerButtonLayout.simplified:
return new SimplifiedPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick);
case PowerButtonLayout.outlined:
return new OutlinedPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick);
case PowerButtonLayout.default_volume:
return new DefaultVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick);
case PowerButtonLayout.simplified_volume:
return new SimplifiedVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick);
case PowerButtonLayout.outlined_volume:
return new OutlinedVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick);
}
}
protected readonly colors: PowerButtonShapeColors;
protected readonly onLabel: string;
protected readonly offLabel: string;
protected backgroundShape: Circle;
protected hoverShape: Circle;
protected hovered = false;
protected pressed = false;
protected forcePressed = false;
protected constructor(protected widgetContext: WidgetContext,
protected svgShape: Svg,
protected settings: PowerButtonWidgetSettings,
protected value: boolean,
protected disabled: boolean,
protected onClick: () => void) {
this.colors = createPowerButtonShapeColors(this.settings);
this.onLabel = this.widgetContext.translate.instant('widgets.power-button.on-label').toUpperCase();
this.offLabel = this.widgetContext.translate.instant('widgets.power-button.off-label').toUpperCase();
this._drawShape();
}
public setValue(value: boolean) {
if (this.value !== value) {
this.value = value;
this._drawState();
}
}
public setDisabled(disabled: boolean) {
if (this.disabled !== disabled) {
this.disabled = disabled;
this._drawState();
}
}
public setPressed(pressed: boolean) {
if (this.forcePressed !== pressed) {
this.forcePressed = pressed;
if (this.forcePressed && !this.pressed) {
this.onPressStart();
} else if (!this.forcePressed && !this.pressed) {
this.onPressEnd();
}
}
}
private _drawShape() {
this.backgroundShape = this.svgShape.circle(powerButtonShapeSize).center(cx, cy)
.fill({opacity: 0}).stroke({width: 0});
this.drawShape();
this.hoverShape = this.svgShape.circle(powerButtonShapeSize).center(cx, cy).addClass('tb-hover-circle')
.fill({color: '#000000', opacity: 0});
this.hoverShape.on('mouseover', () => {
this.hovered = true;
if (!this.disabled) {
this.hoverShape.timeline().finish();
this.hoverShape.animate(200).attr({'fill-opacity': 0.06});
}
});
this.hoverShape.on('mouseout', () => {
this.hovered = false;
this.hoverShape.timeline().finish();
this.hoverShape.animate(200).attr({'fill-opacity': 0});
this._cancelPressed();
});
this.hoverShape.on('touchmove', (event: TouchEvent) => {
const touch = event.touches[0];
const element = document.elementFromPoint(touch.pageX,touch.pageY);
if (this.hoverShape.node !== element) {
this._cancelPressed();
}
});
this.hoverShape.on('touchcancel', () => {
this._cancelPressed();
});
this.hoverShape.on('mousedown touchstart', (event: Event) => {
if (event.type === 'mousedown') {
if ((event as MouseEvent).button !== 0) {
return;
}
}
if (!this.disabled && !this.pressed) {
this.pressed = true;
if (!this.forcePressed) {
this.onPressStart();
}
}
});
this.hoverShape.on('mouseup touchend touchcancel', () => {
if (this.pressed && !this.disabled) {
this.onClick();
}
this._cancelPressed();
});
this._drawState();
}
private _cancelPressed() {
if (this.pressed) {
this.pressed = false;
if (!this.forcePressed) {
this.onPressEnd();
}
}
}
private _drawState() {
let colorState: PowerButtonColorState;
if (this.disabled) {
colorState = this.colors.disabled;
} else {
colorState = this.value ? this.colors.on : this.colors.off;
}
this.drawBackgroundState(colorState.backgroundColor);
this.drawColorState(colorState.mainColor);
if (this.value) {
this.drawOn();
} else {
this.drawOff();
}
if (this.disabled) {
this.hoverShape.timeline().finish();
this.hoverShape.attr({'fill-opacity': 0});
} else if (this.hovered) {
this.hoverShape.timeline().finish();
this.hoverShape.animate(200).attr({'fill-opacity': 0.06});
}
}
private drawBackgroundState(backgroundColor: PowerButtonColor) {
this.backgroundShape.attr({ fill: backgroundColor.hex, 'fill-opacity': backgroundColor.opacity});
}
protected drawShape() {}
protected drawColorState(_mainColor: PowerButtonColor) {}
protected drawOff() {}
protected drawOn() {}
protected onPressStart() {}
protected onPressEnd() {}
protected createMask(shape: Element, maskElements: Element[]) {
const mask =
this.svgShape.mask().add(this.svgShape.rect().width('100%').height('100%').fill('#fff'));
maskElements.forEach(e => {
mask.add(e.fill('#000').attr({'fill-opacity': 1}));
});
shape.maskWith(mask);
}
protected createOnLabel(fontWeight = '500'): Text {
return this.createLabel(this.onLabel, fontWeight);
}
protected createOffLabel(fontWeight = '500'): Text {
return this.createLabel(this.offLabel, fontWeight);
}
private createLabel(text: string, fontWeight = '500'): Text {
return this.svgShape.text(text).font({
family: 'Roboto',
weight: fontWeight,
style: 'normal',
size: '22px'
}).attr({x: '50%', y: '50%', 'text-anchor': 'middle', 'dominant-baseline': 'middle'});
}
}
class InnerShadowCircle {
private shadowCircle: Circle;
private blurEffect: Effect;
private offsetEffect: Effect;
private floodEffect: Effect;
constructor(private svgShape: Svg,
private diameter: number,
private centerX: number,
private centerY: number,
private blur = 6,
private shadowOpacity = 0.6,
private dx = 0,
private dy = 0,
private shadowColor = '#000') {
this.shadowCircle = this.svgShape.circle(this.diameter).center(this.centerX, this.centerY)
.fill({color: '#fff', opacity: 1}).stroke({width: 0});
this.shadowCircle.filterWith(add => {
add.x('-50%').y('-50%').width('200%').height('200%');
let effect: Effect = add.componentTransfer(components => {
components.funcA({ type: 'table', tableValues: '1 0' });
}).in(add.$fill);
effect = effect.gaussianBlur(this.blur, this.blur).attr({stdDeviation: this.blur});
this.blurEffect = effect;
effect = effect.offset(this.dx, this.dy);
this.offsetEffect = effect;
effect = effect.flood(this.shadowColor, this.shadowOpacity);
this.floodEffect = effect;
effect = effect.composite(this.offsetEffect, 'in');
effect.composite(add.$sourceAlpha, 'in');
add.merge(m => {
m.mergeNode(add.$fill);
m.mergeNode();
});
});
}
public timeline(tl: Timeline): void {
this.blurEffect.timeline(tl);
this.offsetEffect.timeline(tl);
this.floodEffect.timeline(tl);
}
public animate(blur: number, opacity: number, dx = 0, dy = 0): Runner {
powerButtonAnimation(this.blurEffect).attr({stdDeviation: blur});
powerButtonAnimation(this.offsetEffect).attr({dx, dy});
return powerButtonAnimation(this.floodEffect).attr({'flood-opacity': opacity});
}
public animateRestore(): Runner {
return this.animate(this.blur, this.shadowOpacity, this.dx, this.dy);
}
public show(): void {
this.shadowCircle.show();
}
public hide(): void {
this.shadowCircle.hide();
}
}
class DefaultPowerButtonShape extends PowerButtonShape {
private outerBorder: Circle;
private outerBorderMask: Circle;
private offLabelShape: Text;
private onCircleShape: Circle;
private onLabelShape: Text;
private pressedShadow: InnerShadowCircle;
private pressedTimeline: Timeline;
private centerGroup: G;
protected drawShape() {
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy)
.fill({opacity: 0}).stroke({width: 0});
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy);
this.createMask(this.outerBorder, [this.outerBorderMask]);
this.centerGroup = this.svgShape.group();
this.offLabelShape = this.createOffLabel().addTo(this.centerGroup);
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 20)
.center(cx, cy);
this.onLabelShape = this.createOnLabel();
this.createMask(this.onCircleShape, [this.onLabelShape]);
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 20, cx, cy, 0, 0);
this.pressedTimeline = new Timeline();
this.centerGroup.timeline(this.pressedTimeline);
this.onLabelShape.timeline(this.pressedTimeline);
this.pressedShadow.timeline(this.pressedTimeline);
}
protected drawColorState(mainColor: PowerButtonColor) {
this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
}
protected drawOff() {
this.outerBorderMask.radius((powerButtonShapeSize - 20)/2);
this.onCircleShape.hide();
this.centerGroup.show();
}
protected drawOn() {
this.outerBorderMask.radius((powerButtonShapeSize - 2)/2);
this.centerGroup.hide();
this.onCircleShape.show();
}
protected onPressStart() {
this.pressedTimeline.finish();
const pressedScale = 0.75;
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale});
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale});
this.pressedShadow.animate(6, 0.6);
}
protected onPressEnd() {
this.pressedTimeline.finish();
powerButtonAnimation(this.centerGroup).transform({scale: 1});
powerButtonAnimation(this.onLabelShape).transform({scale: 1});
this.pressedShadow.animateRestore();
}
}
class SimplifiedPowerButtonShape extends PowerButtonShape {
private outerBorder: Circle;
private outerBorderMask: Circle;
private onCircleShape: Circle;
private offLabelShape: Text;
private onLabelShape: Text;
private pressedShadow: InnerShadowCircle;
private pressedTimeline: Timeline;
private centerGroup: G;
protected drawShape() {
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy)
.fill({opacity: 0}).stroke({width: 0});
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 4).center(cx, cy);
this.createMask(this.outerBorder, [this.outerBorderMask]);
this.centerGroup = this.svgShape.group();
this.offLabelShape = this.createOffLabel().addTo(this.centerGroup);
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize).center(cx, cy);
this.onLabelShape = this.createOnLabel();
this.createMask(this.onCircleShape, [this.onLabelShape]);
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 0, 0);
this.pressedTimeline = new Timeline();
this.centerGroup.timeline(this.pressedTimeline);
this.onLabelShape.timeline(this.pressedTimeline);
this.pressedShadow.timeline(this.pressedTimeline);
}
protected drawColorState(mainColor: PowerButtonColor) {
this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
}
protected drawOff() {
this.onCircleShape.hide();
this.outerBorder.show();
this.centerGroup.show();
}
protected drawOn() {
this.centerGroup.hide();
this.outerBorder.hide();
this.onCircleShape.show();
}
protected onPressStart() {
this.pressedTimeline.finish();
const pressedScale = 0.75;
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale});
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale});
this.pressedShadow.animate(6, 0.6);
}
protected onPressEnd() {
this.pressedTimeline.finish();
powerButtonAnimation(this.centerGroup).transform({scale: 1});
powerButtonAnimation(this.onLabelShape).transform({scale: 1});
this.pressedShadow.animateRestore();
}
}
class OutlinedPowerButtonShape extends PowerButtonShape {
private outerBorder: Circle;
private outerBorderMask: Circle;
private innerBorder: Circle;
private innerBorderMask: Circle;
private offLabelShape: Text;
private onCircleShape: Circle;
private onLabelShape: Text;
private pressedShadow: InnerShadowCircle;
private pressedTimeline: Timeline;
private centerGroup: G;
private onCenterGroup: G;
protected drawShape() {
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy)
.fill({opacity: 0}).stroke({width: 0});
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 2).center(cx, cy);
this.createMask(this.outerBorder, [this.outerBorderMask]);
this.innerBorder = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy)
.fill({opacity: 0}).stroke({width: 0});
this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy);
this.createMask(this.innerBorder, [this.innerBorderMask]);
this.centerGroup = this.svgShape.group();
this.offLabelShape = this.createOffLabel().addTo(this.centerGroup);
this.onCenterGroup = this.svgShape.group();
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 28).center(cx, cy)
.addTo(this.onCenterGroup);
this.onLabelShape = this.createOnLabel();
this.createMask(this.onCircleShape, [this.onLabelShape]);
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 24, cx, cy, 0, 0);
this.pressedTimeline = new Timeline();
this.centerGroup.timeline(this.pressedTimeline);
this.onCenterGroup.timeline(this.pressedTimeline);
this.onLabelShape.timeline(this.pressedTimeline);
this.pressedShadow.timeline(this.pressedTimeline);
}
protected drawColorState(mainColor: PowerButtonColor) {
this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.innerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
}
protected drawOff() {
this.onCenterGroup.hide();
this.centerGroup.show();
}
protected drawOn() {
this.centerGroup.hide();
this.onCenterGroup.show();
}
protected onPressStart() {
this.pressedTimeline.finish();
const pressedScale = 0.75;
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale});
powerButtonAnimation(this.onCenterGroup).transform({scale: 0.98});
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale / 0.98});
this.pressedShadow.animate(6, 0.6);
}
protected onPressEnd() {
this.pressedTimeline.finish();
powerButtonAnimation(this.centerGroup).transform({scale: 1});
powerButtonAnimation(this.onCenterGroup).transform({scale: 1});
powerButtonAnimation(this.onLabelShape).transform({scale: 1});
this.pressedShadow.animateRestore();
}
}
class DefaultVolumePowerButtonShape extends PowerButtonShape {
private outerBorder: Circle;
private outerBorderMask: Circle;
private outerBorderGradient: Gradient;
private innerBorder: Circle;
private innerBorderMask: Circle;
private innerBorderGradient: Gradient;
private innerShadow: InnerShadowCircle;
//private innerShadowGradient: Gradient;
//private innerShadowGradientStop: Stop;
private offLabelShape: Text;
private onCircleShape: Circle;
private onLabelShape: Text;
private pressedTimeline: Timeline;
private centerGroup: G;
protected drawShape() {
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy)
.fill({opacity: 0}).stroke({width: 0});
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy);
this.createMask(this.outerBorder, [this.outerBorderMask]);
this.outerBorderGradient = this.svgShape.gradient('linear', (add) => {
add.stop(0, '#CCCCCC', 1);
add.stop(1, '#FFFFFF', 1);
}).from(0.268, 0.92).to(0.832, 0.1188);
this.innerBorder = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy)
.fill({opacity: 0}).stroke({width: 0});
this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy);
this.createMask(this.innerBorder, [this.innerBorderMask]);
this.innerBorderGradient = this.svgShape.gradient('linear', (add) => {
add.stop(0, '#CCCCCC', 1);
add.stop(1, '#FFFFFF', 1);
}).from(0.832, 0.1188).to(0.268, 0.92);
this.centerGroup = this.svgShape.group();
this.offLabelShape = this.createOffLabel('400').addTo(this.centerGroup);
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy);
this.onLabelShape = this.createOnLabel('400');
this.createMask(this.onCircleShape, [this.onLabelShape]);
this.innerShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 24, cx, cy, 3, 0.3);
this.pressedTimeline = new Timeline();
this.centerGroup.timeline(this.pressedTimeline);
this.onLabelShape.timeline(this.pressedTimeline);
this.innerShadow.timeline(this.pressedTimeline);
}
protected drawColorState(mainColor: PowerButtonColor){
if (this.disabled) {
this.backgroundShape.removeClass('tb-small-shadow');
if (!this.forcePressed) {
this.innerShadow.hide();
}
this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.innerBorder.attr({fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
} else {
this.backgroundShape.addClass('tb-small-shadow');
this.innerShadow.show();
this.outerBorder.fill(this.outerBorderGradient);
this.outerBorder.attr({ 'fill-opacity': 1 });
this.innerBorder.fill(this.innerBorderGradient);
this.innerBorder.attr({ 'fill-opacity': 1 });
}
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
}
protected drawOff() {
this.onCircleShape.hide();
this.centerGroup.show();
this.innerBorder.show();
}
protected drawOn() {
if (this.disabled) {
this.innerBorder.hide();
} else {
this.innerBorder.show();
}
this.centerGroup.hide();
this.onCircleShape.show();
}
protected onPressStart() {
this.pressedTimeline.finish();
this.innerShadow.show();
const pressedScale = 0.75;
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale});
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale});
this.innerShadow.animate(6, 0.6);
}
protected onPressEnd() {
this.pressedTimeline.finish();
powerButtonAnimation(this.centerGroup).transform({scale: 1});
powerButtonAnimation(this.onLabelShape).transform({scale: 1});
this.innerShadow.animateRestore().after(() => {
if (this.disabled) {
this.innerShadow.hide();
}
});
}
}
class SimplifiedVolumePowerButtonShape extends PowerButtonShape {
private outerBorder: Circle;
private outerBorderMask: Circle;
private offLabelShape: Text;
private onLabelShape: Text;
private innerShadow: InnerShadowCircle;
private pressedShadow: InnerShadowCircle;
private pressedTimeline: Timeline;
private centerGroup: G;
private onCenterGroup: G;
protected drawShape() {
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy)
.fill({color: '#FAFAFA', opacity: 1}).stroke({width: 0});
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 4).center(cx, cy);
this.createMask(this.outerBorder, [this.outerBorderMask]);
this.centerGroup = this.svgShape.group();
this.offLabelShape = this.createOffLabel().addTo(this.centerGroup);
this.onCenterGroup = this.svgShape.group();
this.onLabelShape = this.createOnLabel().addTo(this.onCenterGroup);
this.innerShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 3, 0.3);
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 0, 0);
this.pressedTimeline = new Timeline();
this.centerGroup.timeline(this.pressedTimeline);
this.onCenterGroup.timeline(this.pressedTimeline);
this.pressedShadow.timeline(this.pressedTimeline);
}
protected drawColorState(mainColor: PowerButtonColor){
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.onLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
}
protected drawOff() {
if (!this.pressed) {
this.backgroundShape.addClass('tb-shadow');
}
this.innerShadow.hide();
this.onCenterGroup.hide();
this.centerGroup.show();
}
protected drawOn() {
this.backgroundShape.removeClass('tb-shadow');
this.centerGroup.hide();
this.onCenterGroup.show();
this.innerShadow.show();
}
protected onPressStart() {
this.pressedTimeline.finish();
const pressedScale = 0.75;
if (!this.value) {
this.backgroundShape.removeClass('tb-shadow');
}
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale});
powerButtonAnimation(this.onCenterGroup).transform({scale: pressedScale});
this.pressedShadow.animate(8, 0.4);
}
protected onPressEnd() {
this.pressedTimeline.finish();
powerButtonAnimation(this.centerGroup).transform({scale: 1});
powerButtonAnimation(this.onCenterGroup).transform({scale: 1});
this.pressedShadow.animateRestore().after(() => {
if (!this.value) {
this.backgroundShape.addClass('tb-shadow');
}
});
}
}
class OutlinedVolumePowerButtonShape extends PowerButtonShape {
private outerBorder: Circle;
private outerBorderMask: Circle;
private outerBorderGradient: Gradient;
private innerBorder: Circle;
private innerBorderMask: Circle;
private offLabelShape: Text;
private onCircleShape: Circle;
private onLabelShape: Text;
private pressedShadow: InnerShadowCircle;
private pressedTimeline: Timeline;
private centerGroup: G;
private onCenterGroup: G;
protected drawShape() {
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy)
.fill({opacity: 0}).stroke({width: 0});
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy);
this.createMask(this.outerBorder, [this.outerBorderMask]);
this.outerBorderGradient = this.svgShape.gradient('linear', (add) => {
add.stop(0, '#CCCCCC', 1);
add.stop(1, '#FFFFFF', 1);
}).from(0.268, 0.92).to(0.832, 0.1188);
this.innerBorder = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy)
.fill({opacity: 0}).stroke({width: 0});
this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 30).center(cx, cy);
this.createMask(this.innerBorder, [this.innerBorderMask]);
this.centerGroup = this.svgShape.group();
this.offLabelShape = this.createOffLabel('800').addTo(this.centerGroup);
this.onCenterGroup = this.svgShape.group();
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 30).center(cx, cy)
.addTo(this.onCenterGroup);
this.onLabelShape = this.createOnLabel('800');
this.createMask(this.onCircleShape, [this.onLabelShape]);
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 30, cx, cy, 0, 0);
this.backgroundShape.addClass('tb-small-shadow');
this.pressedTimeline = new Timeline();
this.centerGroup.timeline(this.pressedTimeline);
this.onCenterGroup.timeline(this.pressedTimeline);
this.onLabelShape.timeline(this.pressedTimeline);
this.pressedShadow.timeline(this.pressedTimeline);
}
protected drawColorState(mainColor: PowerButtonColor){
if (this.disabled) {
this.outerBorder.attr({ fill: '#000000', 'fill-opacity': 0.03});
} else {
this.outerBorder.fill(this.outerBorderGradient);
this.outerBorder.attr({ 'fill-opacity': 1 });
}
this.innerBorder.attr({fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity});
}
protected drawOff() {
this.onCenterGroup.hide();
this.centerGroup.show();
this.innerBorder.show();
}
protected drawOn() {
this.innerBorder.hide();
this.centerGroup.hide();
this.onCenterGroup.show();
}
protected onPressStart() {
this.pressedTimeline.finish();
const pressedScale = 0.75;
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale});
powerButtonAnimation(this.onCenterGroup).transform({scale: 0.98});
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale / 0.98});
this.pressedShadow.animate(6, 0.6);
}
protected onPressEnd() {
this.pressedTimeline.finish();
powerButtonAnimation(this.centerGroup).transform({scale: 1});
powerButtonAnimation(this.onCenterGroup).transform({scale: 1});
powerButtonAnimation(this.onLabelShape).transform({scale: 1});
this.pressedShadow.animateRestore();
}
}

6
ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html

@ -37,10 +37,4 @@
</div>
</div>
<mat-progress-bar class="tb-action-widget-progress" style="height: 4px;" color="accent" mode="indeterminate" *ngIf="loading$ | async"></mat-progress-bar>
<div *ngIf="error" class="tb-action-widget-error-container">
<div class="tb-action-widget-error-panel">
<div class="tb-action-widget-error-text" [innerHTML]="error | safe: 'html'"></div>
<button class="tb-action-widget-error-clear tb-mat-20" mat-icon-button (click)="clearError()"><mat-icon>close</mat-icon></button>
</div>
</div>
</div>

59
ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.html

@ -0,0 +1,59 @@
<!--
Copyright © 2016-2024 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.
-->
<div class="tb-slider-panel" [style.pointer-events]="ctx.isEdit ? 'none' : 'all'" [style]="backgroundStyle$ | async">
<div class="tb-slider-overlay" [style]="overlayStyle"></div>
<div *ngIf="showWidgetTitlePanel" class="tb-slider-title-panel">
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
</div>
<div #sliderContent class="tb-slider-content">
<div #sliderValueContainer class="tb-slider-value-container" *ngIf="showValue" >
<div #sliderValue class="tb-slider-value" [style]="valueStyle">{{ valueText }}</div>
</div>
<div class="tb-slider-container" [class.tb-min-height]="!showValue">
<div #leftSliderIconContainer *ngIf="showLeftRightIcon" >
<tb-icon #leftSliderIcon [style]="leftIconStyle"
[style.color]="(disabled || (loading$ | async)) ? settings.mainColorDisabled : settings.leftIconColor">{{ leftIcon }}</tb-icon>
</div>
<div class="tb-slider-column">
<mat-slider class="tb-slider"
[disabled]="disabled || (loading$ | async)"
[displayWith]="sliderValueText"
[showTickMarks]="settings.showTickMarks"
[step]="sliderStep"
[min]="settings.tickMin"
[max]="settings.tickMax"
discrete>
<input matSliderThumb [(ngModel)]="value" (valueChange)="onSliderChange()">
</mat-slider>
<div *ngIf="showTicks" class="tb-slider-ticks" [style]="ticksStyle">
<div #sliderTickMinContainer>
<div #sliderTickMin>{{ settings.tickMin }}</div>
</div>
<div #sliderTickMaxContainer>
<div #sliderTickMax>{{ settings.tickMax }}</div>
</div>
</div>
</div>
<div #rightSliderIconContainer *ngIf="showLeftRightIcon" >
<tb-icon #rightSliderIcon [style]="rightIconStyle"
[style.color]="(disabled || (loading$ | async)) ? settings.mainColorDisabled : settings.rightIconColor">{{ rightIcon }}</tb-icon>
</div>
</div>
</div>
<mat-progress-bar class="tb-action-widget-progress" style="height: 4px;" color="accent" mode="indeterminate" *ngIf="loading$ | async"></mat-progress-bar>
</div>

135
ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.scss

@ -0,0 +1,135 @@
/**
* Copyright © 2016-2024 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.
*/
$mainColor: var(--tb-slider-main-color, #5469FF);
$hoverRippleColor: var(--tb-slider-hover-ripple-color, rgba(84, 105, 255, 0.05));
$focusRippleColor: var(--tb-slider-focus-ripple-color, rgba(84, 105, 255, 0.2));
$backgroundColor: var(--tb-slider-background-color, #CCD2FF);
$tickMarksColor: var(--tb-slider-tick-marks-color, #5469FF);
$mainColorDisabled: var(--tb-slider-main-color-disabled, #9BA2B0);
$backgroundColorDisabled: var(--tb-slider-background-color-disabled, #D5D7E5);
.tb-slider-panel {
.mat-mdc-slider.mat-primary.tb-slider {
--mdc-slider-active-track-color: #{$mainColor};
--mdc-slider-handle-color: #{$mainColor};
--mdc-slider-focus-handle-color: #{$mainColor};
--mdc-slider-hover-handle-color: #{$mainColor};
--mdc-slider-with-tick-marks-inactive-container-color: #{$tickMarksColor};
--mat-mdc-slider-ripple-color: #{$mainColor};
--mat-mdc-slider-hover-ripple-color: #{$hoverRippleColor};
--mat-mdc-slider-focus-ripple-color: #{$focusRippleColor};
--mdc-slider-inactive-track-color: #{$backgroundColor};
--mdc-slider-disabled-active-track-color: #{$mainColorDisabled};
--mdc-slider-disabled-handle-color: #{$mainColorDisabled};
--mdc-slider-disabled-inactive-track-color: #{$backgroundColorDisabled};
--mdc-slider-with-tick-marks-disabled-container-color: #{$mainColorDisabled};
--mdc-slider-handle-width: 16px;
--mdc-slider-handle-height: 16px;
}
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
padding: 20px 24px 24px 24px;
gap: 8px;
> div:not(.tb-slider-overlay), > tb-icon {
z-index: 1;
}
.tb-slider-overlay {
position: absolute;
inset: 12px;
}
div.tb-slider-title-panel {
z-index: 2;
}
.tb-slider-content {
flex: 1;
min-height: 0;
display: flex;
position: relative;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
.tb-slider-value-container {
min-height: 0;
}
.tb-slider-value {
white-space: nowrap;
}
.tb-slider-container {
align-self: stretch;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
&.tb-min-height {
height: 6px;
}
.tb-slider-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
.mat-mdc-slider.tb-slider {
margin: 0;
height: 6px;
min-height: 6px;
min-width: 0;
&.mdc-slider--disabled {
opacity: 1;
}
.mdc-slider__track--inactive {
opacity: 1;
}
.mdc-slider__tick-marks {
.mdc-slider__tick-mark--active {
display: none;
}
.mdc-slider__tick-mark--inactive {
opacity: 1;
}
}
.mdc-slider__thumb.mat-mdc-slider-visual-thumb {
top: -21px;
.mat-ripple {
overflow: visible;
}
}
.mdc-slider__value-indicator-text {
white-space: nowrap;
}
}
.tb-slider-ticks {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
}
}
}
}

347
ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.ts

@ -0,0 +1,347 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
OnDestroy,
OnInit,
Renderer2,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget/lib/action/action-widget.models';
import {
backgroundStyle,
ComponentStyle,
iconStyle,
overlayStyle,
textStyle
} from '@shared/models/widget-settings.models';
import { Observable } from 'rxjs';
import { ResizeObserver } from '@juggle/resize-observer';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
import { ValueType } from '@shared/models/constants';
import { UtilsService } from '@core/services/utils.service';
import {
SliderLayout,
sliderWidgetDefaultSettings,
SliderWidgetSettings
} from '@home/components/widget/lib/rpc/slider-widget.models';
import { formatValue, isDefinedAndNotNull, isNumeric } from '@core/utils';
import { WidgetComponent } from '@home/components/widget/widget.component';
import tinycolor from 'tinycolor2';
@Component({
selector: 'tb-slider-widget',
templateUrl: './slider-widget.component.html',
styleUrls: ['../action/action-widget.scss', './slider-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class SliderWidgetComponent extends
BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('sliderContent', {static: false})
sliderContent: ElementRef<HTMLElement>;
@ViewChild('sliderValueContainer', {static: false})
sliderValueContainer: ElementRef<HTMLElement>;
@ViewChild('sliderValue', {static: false})
sliderValue: ElementRef<HTMLElement>;
@ViewChild('sliderTickMinContainer', {static: false})
sliderTickMinContainer: ElementRef<HTMLElement>;
@ViewChild('sliderTickMin', {static: false})
sliderTickMin: ElementRef<HTMLElement>;
@ViewChild('sliderTickMaxContainer', {static: false})
sliderTickMaxContainer: ElementRef<HTMLElement>;
@ViewChild('sliderTickMax', {static: false})
sliderTickMax: ElementRef<HTMLElement>;
@ViewChild('leftSliderIconContainer', {static: false, read: ElementRef})
leftSliderIconContainer: ElementRef<HTMLElement>;
@ViewChild('leftSliderIcon', {static: false, read: ElementRef})
leftSliderIcon: ElementRef<HTMLElement>;
@ViewChild('rightSliderIconContainer', {static: false, read: ElementRef})
rightSliderIconContainer: ElementRef<HTMLElement>;
@ViewChild('rightSliderIcon', {static: false, read: ElementRef})
rightSliderIcon: ElementRef<HTMLElement>;
settings: SliderWidgetSettings;
backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {};
value: number = null;
private prevValue: number = null;
disabled = false;
layout: SliderLayout;
showValue = true;
valueText = 'N/A';
valueStyle: ComponentStyle = {};
showLeftRightIcon = false;
leftIcon = '';
leftIconStyle: ComponentStyle = {};
rightIcon = '';
rightIconStyle: ComponentStyle = {};
showTicks = true;
ticksStyle: ComponentStyle = {};
sliderStep: number = undefined;
autoScale = false;
showWidgetTitlePanel = this.widgetComponent.dashboardWidget.showWidgetTitlePanel;
sliderValueText = this._sliderValueText.bind(this);
private panelResize$: ResizeObserver;
private valueSetter: ValueSetter<number>;
private sliderCssClass: string;
constructor(protected imagePipe: ImagePipe,
protected sanitizer: DomSanitizer,
private renderer: Renderer2,
private utils: UtilsService,
private widgetComponent: WidgetComponent,
protected cd: ChangeDetectorRef,
private elementRef: ElementRef) {
super(cd);
}
ngOnInit(): void {
super.ngOnInit();
this.settings = {...sliderWidgetDefaultSettings, ...this.ctx.settings};
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.layout = this.settings.layout;
this.autoScale = this.settings.autoScale;
this.showValue = this.layout !== SliderLayout.simplified && this.settings.showValue;
this.valueStyle = textStyle(this.settings.valueFont);
this.valueStyle.color = this.settings.valueColor;
this.showLeftRightIcon = this.layout === SliderLayout.extended;
if (this.showLeftRightIcon) {
this.leftIcon = this.settings.leftIcon;
this.leftIconStyle = iconStyle(this.settings.leftIconSize, this.settings.leftIconSizeUnit );
this.rightIcon = this.settings.rightIcon;
this.rightIconStyle = iconStyle(this.settings.rightIconSize, this.settings.rightIconSizeUnit );
if (!this.autoScale) {
const leftIconMargin = this.settings.leftIconSize / 2 + (this.settings.leftIconSizeUnit || 'px');
this.leftIconStyle.marginTop = `calc(-${leftIconMargin} + 3px)`;
const rightIconMargin = this.settings.rightIconSize / 2 + (this.settings.rightIconSizeUnit || 'px');
this.rightIconStyle.marginTop = `calc(-${rightIconMargin} + 3px)`;
}
}
this.showTicks = this.settings.showTicks;
if (this.showTicks) {
this.ticksStyle = textStyle(this.settings.ticksFont);
this.ticksStyle.color = this.settings.ticksColor;
}
if (this.settings.showTickMarks) {
const range = this.settings.tickMax - this.settings.tickMin;
this.sliderStep = range / (this.settings.tickMarksCount - 1);
}
const mainColorInstance = tinycolor(this.settings.mainColor);
const hoverRippleColor = mainColorInstance.clone().setAlpha(mainColorInstance.getAlpha() * 0.05).toRgbString();
const focusRippleColor = mainColorInstance.clone().setAlpha(mainColorInstance.getAlpha() * 0.2).toRgbString();
const sliderVariablesCss = `.tb-slider-panel {\n`+
`--tb-slider-main-color: ${this.settings.mainColor};\n`+
`--tb-slider-background-color: ${this.settings.backgroundColor};\n`+
`--tb-slider-hover-ripple-color: ${hoverRippleColor};\n`+
`--tb-slider-focus-ripple-color: ${focusRippleColor};\n`+
`--tb-slider-tick-marks-color: ${this.settings.tickMarksColor};\n`+
`--tb-slider-main-color-disabled: ${this.settings.mainColorDisabled};\n`+
`--tb-slider-background-disabled: ${this.settings.backgroundColorDisabled};\n`+
`}`;
this.sliderCssClass =
this.utils.applyCssToElement(this.renderer, this.elementRef.nativeElement, 'tb-slider', sliderVariablesCss);
const getInitialStateSettings =
{...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.slider.initial-value')};
this.createValueGetter(getInitialStateSettings, ValueType.INTEGER, {
next: (value) => this.onValue(value)
});
const disabledStateSettings =
{...this.settings.disabledState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.disabled-state')};
this.createValueGetter(disabledStateSettings, ValueType.BOOLEAN, {
next: (value) => this.onDisabled(value)
});
const valueChangeSettings = {...this.settings.valueChange,
actionLabel: this.ctx.translate.instant('widgets.slider.on-value-change')};
this.valueSetter = this.createValueSetter(valueChangeSettings);
}
ngAfterViewInit(): void {
if (this.autoScale) {
this.panelResize$ = new ResizeObserver(() => {
this.onResize();
});
this.panelResize$.observe(this.sliderContent.nativeElement);
if (this.showValue) {
this.panelResize$.observe(this.sliderValueContainer.nativeElement);
}
this.onResize();
}
super.ngAfterViewInit();
}
ngOnDestroy() {
if (this.panelResize$) {
this.panelResize$.disconnect();
}
if (this.sliderCssClass) {
this.utils.clearCssElement(this.renderer, this.sliderCssClass);
}
super.ngOnDestroy();
}
public onInit() {
super.onInit();
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
this.cd.detectChanges();
}
public onSliderChange() {
this.updateValueText();
if (!this.ctx.isEdit && !this.ctx.isPreview) {
const prevValue = this.prevValue;
const targetValue = this.value;
this.updateValue(this.valueSetter, targetValue, {
next: () => this.onValue(targetValue),
error: () => this.onValue(prevValue)
});
}
}
private _sliderValueText(value: number): string {
return formatValue(value, this.settings.valueDecimals, this.settings.valueUnits, false);
}
private onValue(value: number): void {
this.value = value;
this.prevValue = value;
this.updateValueText();
this.cd.markForCheck();
}
private updateValueText() {
if (isDefinedAndNotNull(this.value) && isNumeric(this.value)) {
this.valueText = formatValue(this.value, this.settings.valueDecimals, this.settings.valueUnits, false);
} else {
this.valueText = 'N/A';
}
}
private onDisabled(value: boolean): void {
this.disabled = !!value;
this.cd.markForCheck();
}
private onResize() {
const panelWidth = this.sliderContent.nativeElement.getBoundingClientRect().width;
const panelHeight = this.sliderContent.nativeElement.getBoundingClientRect().height;
if (this.showValue) {
this.resetScale(this.sliderValueContainer.nativeElement, this.sliderValue.nativeElement);
}
if (this.showLeftRightIcon) {
this.resetScale(this.leftSliderIconContainer.nativeElement, this.leftSliderIcon.nativeElement);
this.resetScale(this.rightSliderIconContainer.nativeElement, this.rightSliderIcon.nativeElement);
}
if (this.showTicks) {
this.resetScale(this.sliderTickMinContainer.nativeElement, this.sliderTickMin.nativeElement);
this.resetScale(this.sliderTickMaxContainer.nativeElement, this.sliderTickMax.nativeElement);
}
let minAspect = 0.2;
let avgContentHeight = 35;
if (this.showTicks) {
minAspect += 0.1;
avgContentHeight += 20;
}
if (this.showValue) {
minAspect += 0.1;
avgContentHeight += 50;
}
const aspect = Math.min(panelHeight / panelWidth, minAspect);
const targetHeight = panelWidth * aspect;
const scale = targetHeight / avgContentHeight;
if (this.showValue) {
this.updateScale(this.sliderValueContainer.nativeElement, this.sliderValue.nativeElement, scale);
}
if (this.showLeftRightIcon) {
const leftIconContainerRect = this.leftSliderIconContainer.nativeElement.getBoundingClientRect();
const leftIconContainerMarginTop = -(leftIconContainerRect.width * scale) / 2 + 3;
this.renderer.setStyle(this.leftSliderIconContainer.nativeElement, 'marginTop', `${leftIconContainerMarginTop}px`);
this.updateScale(this.leftSliderIconContainer.nativeElement, this.leftSliderIcon.nativeElement, scale, true);
const rightIconContainerRect = this.rightSliderIconContainer.nativeElement.getBoundingClientRect();
const rightIconContainerMarginTop = -(rightIconContainerRect.width * scale) / 2 + 3;
this.renderer.setStyle(this.rightSliderIconContainer.nativeElement, 'marginTop', `${rightIconContainerMarginTop}px`);
this.updateScale(this.rightSliderIconContainer.nativeElement, this.rightSliderIcon.nativeElement, scale, true);
}
if (this.showTicks) {
this.updateScale(this.sliderTickMinContainer.nativeElement, this.sliderTickMin.nativeElement, scale);
this.updateScale(this.sliderTickMaxContainer.nativeElement, this.sliderTickMax.nativeElement, scale);
}
}
private resetScale(container: HTMLElement, element: HTMLElement): void {
this.renderer.setStyle(container, 'width', '');
this.renderer.setStyle(container, 'height', '');
this.renderer.setStyle(element, 'transform', '');
}
private updateScale(container: HTMLElement, element: HTMLElement, scale: number, sameHeight = false): void {
const rect = container.getBoundingClientRect();
this.renderer.setStyle(container, 'width', `${rect.width * scale}px`);
this.renderer.setStyle(container, 'height', `${(sameHeight ? rect.width : rect.height) * scale}px`);
this.renderer.setStyle(element, 'transform', `scale(${scale})`);
this.renderer.setStyle(element, 'transform-origin', 'left top');
}
}

196
ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.models.ts

@ -0,0 +1,196 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
DataToValueType,
GetValueAction,
GetValueSettings,
SetValueAction,
SetValueSettings,
ValueToDataType
} from '@shared/models/action-widget-settings.models';
import { BackgroundSettings, BackgroundType, cssUnit, Font } from '@shared/models/widget-settings.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
export enum SliderLayout {
default = 'default',
extended = 'extended',
simplified = 'simplified'
}
export const sliderLayouts = Object.keys(SliderLayout) as SliderLayout[];
export const sliderLayoutTranslations = new Map<SliderLayout, string>(
[
[SliderLayout.default, 'widgets.slider.layout-default'],
[SliderLayout.extended, 'widgets.slider.layout-extended'],
[SliderLayout.simplified, 'widgets.slider.layout-simplified']
]
);
export const sliderLayoutImages = new Map<SliderLayout, string>(
[
[SliderLayout.default, 'assets/widget/slider/default-layout.svg'],
[SliderLayout.extended, 'assets/widget/slider/extended-layout.svg'],
[SliderLayout.simplified, 'assets/widget/slider/simplified-layout.svg']
]
);
export interface SliderWidgetSettings {
initialState: GetValueSettings<number>;
disabledState: GetValueSettings<boolean>;
valueChange: SetValueSettings;
layout: SliderLayout;
autoScale: boolean;
showValue: boolean;
valueUnits: string;
valueDecimals: number;
valueFont: Font;
valueColor: string;
showTicks: boolean;
tickMin: number;
tickMax: number;
ticksFont: Font;
ticksColor: string;
showTickMarks: boolean;
tickMarksCount: number;
tickMarksColor: string;
mainColor: string;
backgroundColor: string;
mainColorDisabled: string;
backgroundColorDisabled: string;
leftIcon: string;
leftIconSize: number;
leftIconSizeUnit: cssUnit;
leftIconColor: string;
rightIcon: string;
rightIconSize: number;
rightIconSizeUnit: cssUnit;
rightIconColor: string;
background: BackgroundSettings;
}
export const sliderWidgetDefaultSettings: SliderWidgetSettings = {
initialState: {
action: GetValueAction.EXECUTE_RPC,
defaultValue: 0,
executeRpc: {
method: 'getState',
requestTimeout: 5000,
requestPersistent: false,
persistentPollingInterval: 1000
},
getAttribute: {
key: 'state',
scope: null
},
getTimeSeries: {
key: 'state'
},
dataToValue: {
type: DataToValueType.NONE,
compareToValue: true,
dataToValueFunction: '/* Should return integer value */\nreturn data;'
}
},
disabledState: {
action: GetValueAction.DO_NOTHING,
defaultValue: false,
getAttribute: {
key: 'state',
scope: null
},
getTimeSeries: {
key: 'state'
},
dataToValue: {
type: DataToValueType.NONE,
compareToValue: true,
dataToValueFunction: '/* Should return boolean value */\nreturn data;'
}
},
valueChange: {
action: SetValueAction.EXECUTE_RPC,
executeRpc: {
method: 'setState',
requestTimeout: 5000,
requestPersistent: false,
persistentPollingInterval: 1000
},
setAttribute: {
key: 'state',
scope: AttributeScope.SHARED_SCOPE
},
putTimeSeries: {
key: 'state'
},
valueToData: {
type: ValueToDataType.VALUE,
constantValue: 0,
valueToDataFunction: '/* Convert input integer value to RPC parameters or attribute/time-series value */\nreturn value;'
}
},
layout: SliderLayout.default,
autoScale: true,
showValue: true,
valueUnits: '%',
valueDecimals: 0,
valueFont: {
family: 'Roboto',
size: 36,
sizeUnit: 'px',
style: 'normal',
weight: '500',
lineHeight: '36px'
},
valueColor: 'rgba(0, 0, 0, 0.87)',
showTicks: true,
tickMin: 0,
tickMax: 100,
ticksFont: {
family: 'Roboto',
size: 11,
sizeUnit: 'px',
style: 'normal',
weight: '400',
lineHeight: '16px'
},
ticksColor: 'rgba(0,0,0,0.54)',
showTickMarks: true,
tickMarksCount: 11,
tickMarksColor: '#5469FF',
mainColor: '#5469FF',
backgroundColor: '#CCD2FF',
mainColorDisabled: '#9BA2B0',
backgroundColorDisabled: '#D5D7E5',
leftIcon: 'lightbulb',
leftIconSize: 24,
leftIconSizeUnit: 'px',
leftIconColor: '#5469FF',
rightIcon: 'mdi:lightbulb-on',
rightIconSize: 24,
rightIconSizeUnit: 'px',
rightIconColor: '#5469FF',
background: {
type: BackgroundType.color,
color: '#fff',
overlay: {
enabled: false,
color: 'rgba(255,255,255,0.72)',
blur: 3
}
}
};

142
ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.html

@ -0,0 +1,142 @@
<!--
Copyright © 2016-2024 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.
-->
<ng-container [formGroup]="powerButtonWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.power-button.behavior</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.initial-state-hint' | translate}}" translate>widgets.rpc-state.initial-state</div>
<tb-get-value-action-settings fxFlex
panelTitle="widgets.rpc-state.initial-state"
[valueType]="valueType.BOOLEAN"
trueLabel="widgets.rpc-state.on"
falseLabel="widgets.rpc-state.off"
stateLabel="widgets.rpc-state.on"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="initialState"></tb-get-value-action-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.power-button.power-on-hint' | translate}}" translate>widgets.power-button.power-on</div>
<tb-set-value-action-settings fxFlex
panelTitle="widgets.power-button.power-on "
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="onUpdateState"></tb-set-value-action-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.power-button.power-off-hint' | translate}}" translate>widgets.power-button.power-off</div>
<tb-set-value-action-settings fxFlex
panelTitle="widgets.power-button.power-off"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="offUpdateState"></tb-set-value-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.disabled-state-hint' | translate}}" translate>widgets.rpc-state.disabled-state</div>
<tb-get-value-action-settings fxFlex
panelTitle="widgets.rpc-state.disabled-state"
[valueType]="valueType.BOOLEAN"
stateLabel="widgets.rpc-state.disabled"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="disabledState"></tb-get-value-action-settings>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-style</div>
<tb-image-cards-select rowHeight="1:1"
[cols]="{columns: 3,
breakpoints: {
'lt-sm': 1,
'lt-md': 2
}}"
label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of powerButtonLayouts"
[value]="layout"
[image]="powerButtonLayoutImageMap.get(layout)">
{{ powerButtonLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.power-button.button</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.power-button.power-on-colors' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.main</div>
<tb-color-input asBoxInput
formControlName="mainColorOn">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColorOn">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.power-button.power-off-colors' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.main</div>
<tb-color-input asBoxInput
formControlName="mainColorOff">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColorOff">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.power-button.disabled-colors' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.main</div>
<tb-color-input asBoxInput
formControlName="mainColorDisabled">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.power-button.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColorDisabled">
</tb-color-input>
</div>
</div>
</div>
</div>
</ng-container>

88
ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.ts

@ -0,0 +1,88 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component } from '@angular/core';
import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { ValueType } from '@shared/models/constants';
import {
powerButtonDefaultSettings,
powerButtonLayoutImages,
powerButtonLayouts,
powerButtonLayoutTranslations
} from '@home/components/widget/lib/rpc/power-button-widget.models';
@Component({
selector: 'tb-power-button-widget-settings',
templateUrl: './power-button-widget-settings.component.html',
styleUrls: ['./../widget-settings.scss']
})
export class PowerButtonWidgetSettingsComponent extends WidgetSettingsComponent {
get targetDevice(): TargetDevice {
return this.widgetConfig?.config?.targetDevice;
}
get widgetType(): widgetType {
return this.widgetConfig?.widgetType;
}
powerButtonLayouts = powerButtonLayouts;
powerButtonLayoutTranslationMap = powerButtonLayoutTranslations;
powerButtonLayoutImageMap = powerButtonLayoutImages;
valueType = ValueType;
powerButtonWidgetSettingsForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.powerButtonWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return {...powerButtonDefaultSettings};
}
protected onSettingsSet(settings: WidgetSettings) {
this.powerButtonWidgetSettingsForm = this.fb.group({
initialState: [settings.initialState, []],
onUpdateState: [settings.onUpdateState, []],
offUpdateState: [settings.offUpdateState, []],
disabledState: [settings.disabledState, []],
layout: [settings.layout, []],
mainColorOn: [settings.mainColorOn, []],
backgroundColorOn: [settings.backgroundColorOn, []],
mainColorOff: [settings.mainColorOff, []],
backgroundColorOff: [settings.backgroundColorOff, []],
mainColorDisabled: [settings.mainColorDisabled, []],
backgroundColorDisabled: [settings.backgroundColorDisabled, []],
background: [settings.background, []]
});
}
}

4
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/progress-bar-widget-settings.component.html

@ -35,7 +35,7 @@
{{ 'widgets.progress-bar.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row space-between column-xs">
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="showValue">
{{ 'widgets.progress-bar.value' | translate }}
</mat-slide-toggle>
@ -48,7 +48,7 @@
</tb-color-settings>
</div>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.progress-bar.range' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-small-label" translate>widgets.progress-bar.min</div>

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.html

@ -97,7 +97,8 @@
<div class="fixed-title-width">{{ (setValueSettingsFormGroup.get('action').value === setValueAction.EXECUTE_RPC ?
'widgets.value-action.parameters' : 'widgets.value-action.value') | translate }}</div>
<tb-toggle-select fxFlex formControlName="type">
<tb-toggle-option [value]="valueToDataType.CONSTANT">{{ 'widgets.value-action.converter-constant' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="valueToDataType.VALUE">{{ 'widgets.value-action.converter-value' | translate }}</tb-toggle-option>
<tb-toggle-option *ngIf="valueType === ValueType.BOOLEAN" [value]="valueToDataType.CONSTANT">{{ 'widgets.value-action.converter-constant' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="valueToDataType.FUNCTION">{{ 'widgets.value-action.converter-function' | translate }}</tb-toggle-option>
<tb-toggle-option *ngIf="setValueSettingsFormGroup.get('action').value === setValueAction.EXECUTE_RPC"
[value]="valueToDataType.NONE">{{ 'widgets.value-action.converter-none' | translate }}</tb-toggle-option>

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.ts

@ -32,6 +32,7 @@ import { TargetDevice, widgetType } from '@shared/models/widget.models';
import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models';
import { IAliasController } from '@core/api/widget-api.models';
import { WidgetService } from '@core/http/widget.service';
import { ValueType } from '@shared/models/constants';
@Component({
selector: 'tb-set-value-action-settings-panel',
@ -45,6 +46,9 @@ export class SetValueActionSettingsPanelComponent extends PageComponent implemen
@Input()
panelTitle: string;
@Input()
valueType = ValueType.BOOLEAN;
@Input()
setValueSettings: SetValueSettings;
@ -79,6 +83,8 @@ export class SetValueActionSettingsPanelComponent extends PageComponent implemen
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
ValueType = ValueType;
setValueSettingsFormGroup: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder,

8
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings.component.ts

@ -36,6 +36,7 @@ import { isDefinedAndNotNull } from '@core/utils';
import {
SetValueActionSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component';
import { ValueType } from '@shared/models/constants';
@Component({
selector: 'tb-set-value-action-settings',
@ -58,6 +59,9 @@ export class SetValueActionSettingsComponent implements OnInit, ControlValueAcce
@Input()
panelTitle: string;
@Input()
valueType = ValueType.BOOLEAN;
@Input()
aliasController: IAliasController;
@ -114,6 +118,7 @@ export class SetValueActionSettingsComponent implements OnInit, ControlValueAcce
const ctx: any = {
setValueSettings: this.modelValue,
panelTitle: this.panelTitle,
valueType: this.valueType,
aliasController: this.aliasController,
targetDevice: this.targetDevice,
widgetType: this.widgetType
@ -137,6 +142,9 @@ export class SetValueActionSettingsComponent implements OnInit, ControlValueAcce
private updateDisplayValue() {
let value: any;
switch (this.modelValue.valueToData.type) {
case ValueToDataType.VALUE:
value = 'value';
break;
case ValueToDataType.CONSTANT:
value = this.modelValue.valueToData.constantValue;
break;

19
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html

@ -80,6 +80,22 @@
</mat-form-field>
</div>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionType.openURL">
<div class="tb-form-row">
<div class="tb-required">{{ 'widget-action.URL' | translate }}</div>
<mat-form-field fxFlex style="margin-left: 9px" appearance="outline" subscriptSizing="dynamic">
<input matInput placeholder="{{ 'widget-config.set' | translate }}" formControlName="url">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'widget-action.url-required' | translate"
*ngIf="actionTypeFormGroup.get('url').invalid && actionTypeFormGroup.get('url').touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState ||
widgetActionFormGroup.get('type').value === widgetActionType.updateDashboardState ?
widgetActionFormGroup.get('type').value : ''">
@ -89,7 +105,8 @@
</mat-slide-toggle>
</div>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboard ?
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboard ||
widgetActionFormGroup.get('type').value === widgetActionType.openURL ?
widgetActionFormGroup.get('type').value : ''">
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="openNewBrowserTab">

10
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts

@ -279,6 +279,16 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali
this.fb.control(action ? action.mobileAction : null, [Validators.required])
);
break;
case WidgetActionType.openURL:
this.actionTypeFormGroup.addControl(
'openNewBrowserTab',
this.fb.control(action ? action.openNewBrowserTab : false, [])
);
this.actionTypeFormGroup.addControl(
'url',
this.fb.control(action ? action.url : null, [Validators.required])
);
break;
}
}
this.actionTypeFormGroupSubscriptions.push(

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.html

@ -56,7 +56,7 @@
</tb-material-icon-select>
</div>
</div>
<div class="tb-form-row space-between">
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.button.color-palette' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">

4
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html

@ -81,7 +81,7 @@
{{ 'widgets.single-switch.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel">
{{ 'widgets.single-switch.label' | translate }}
</mat-slide-toggle>
@ -98,7 +98,7 @@
</tb-color-input>
</div>
</div>
<div class="tb-form-row">
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.single-switch.icon' | translate }}
</mat-slide-toggle>

216
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.html

@ -0,0 +1,216 @@
<!--
Copyright © 2016-2024 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.
-->
<ng-container [formGroup]="sliderWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.slider.behavior</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.slider.initial-value-hint' | translate}}" translate>widgets.slider.initial-value</div>
<tb-get-value-action-settings fxFlex
panelTitle="widgets.slider.initial-value"
[valueType]="valueType.DOUBLE"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="initialState"></tb-get-value-action-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.slider.on-value-change-hint' | translate}}" translate>widgets.slider.on-value-change</div>
<tb-set-value-action-settings fxFlex
panelTitle="widgets.slider.on-value-change"
[valueType]="valueType.DOUBLE"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="valueChange"></tb-set-value-action-settings>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.disabled-state-hint' | translate}}" translate>widgets.rpc-state.disabled-state</div>
<tb-get-value-action-settings fxFlex
panelTitle="widgets.rpc-state.disabled-state"
[valueType]="valueType.BOOLEAN"
stateLabel="widgets.rpc-state.disabled"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[widgetType]="widgetType"
formControlName="disabledState"></tb-get-value-action-settings>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-style</div>
<tb-image-cards-select rowHeight="2:1"
[cols]="{columns: 3,
breakpoints: {
'lt-sm': 1,
'lt-md': 2
}}"
label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of sliderLayouts"
[value]="layout"
[image]="sliderLayoutImageMap.get(layout)">
{{ sliderLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.slider.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.slider.slider</div>
<div *ngIf="sliderWidgetSettingsForm.get('layout').value !== sliderLayout.simplified" class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showValue">
{{ 'widgets.slider.value' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-unit-input class="flex" formControlName="valueUnits"></tb-unit-input>
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput formControlName="valueDecimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix fxHide.lt-md translate>widget-config.decimals-suffix</div>
</mat-form-field>
<tb-font-settings formControlName="valueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="valueColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.slider.range' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-small-label" translate>widgets.slider.min</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tickMin" type="number" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<div class="tb-small-label" translate>widgets.slider.max</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tickMax" type="number" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="showTicks">
{{ 'widgets.slider.range-ticks' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="ticksFont"
previewText="100">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="ticksColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="showTickMarks">
{{ 'widgets.slider.tick-marks' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tickMarksCount" type="number" min="2"
step="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-color-input asBoxInput
formControlName="tickMarksColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.slider.colors' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.slider.main</div>
<tb-color-input asBoxInput
formControlName="mainColor">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.slider.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.rpc-state.disabled-state' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.slider.main</div>
<tb-color-input asBoxInput
formControlName="mainColorDisabled">
</tb-color-input>
</div>
<mat-divider vertical></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.slider.background</div>
<tb-color-input asBoxInput
formControlName="backgroundColorDisabled">
</tb-color-input>
</div>
</div>
</div>
<div *ngIf="sliderWidgetSettingsForm.get('layout').value === sliderLayout.extended"
class="tb-form-row column-xs">
<div class="fixed-title-width">
{{ 'widgets.slider.left-icon' | translate }}
</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="leftIconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="leftIconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
[color]="sliderWidgetSettingsForm.get('leftIconColor').value"
formControlName="leftIcon">
</tb-material-icon-select>
<tb-color-input asBoxInput
formControlName="leftIconColor">
</tb-color-input>
</div>
</div>
<div *ngIf="sliderWidgetSettingsForm.get('layout').value === sliderLayout.extended"
class="tb-form-row column-xs">
<div class="fixed-title-width">
{{ 'widgets.slider.right-icon' | translate }}
</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="rightIconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="rightIconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
[color]="sliderWidgetSettingsForm.get('rightIconColor').value"
formControlName="rightIcon">
</tb-material-icon-select>
<tb-color-input asBoxInput
formControlName="rightIconColor">
</tb-color-input>
</div>
</div>
</div>
</ng-container>

186
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.ts

@ -0,0 +1,186 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component } from '@angular/core';
import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { ValueType } from '@shared/models/constants';
import {
SliderLayout,
sliderLayoutImages,
sliderLayouts,
sliderLayoutTranslations,
sliderWidgetDefaultSettings
} from '@home/components/widget/lib/rpc/slider-widget.models';
import { formatValue } from '@core/utils';
@Component({
selector: 'tb-slider-widget-settings',
templateUrl: './slider-widget-settings.component.html',
styleUrls: ['./../widget-settings.scss']
})
export class SliderWidgetSettingsComponent extends WidgetSettingsComponent {
get targetDevice(): TargetDevice {
return this.widgetConfig?.config?.targetDevice;
}
get widgetType(): widgetType {
return this.widgetConfig?.widgetType;
}
sliderLayout = SliderLayout;
sliderLayouts = sliderLayouts;
sliderLayoutTranslationMap = sliderLayoutTranslations;
sliderLayoutImageMap = sliderLayoutImages;
valueType = ValueType;
sliderWidgetSettingsForm: UntypedFormGroup;
valuePreviewFn = this._valuePreviewFn.bind(this);
constructor(protected store: Store<AppState>,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.sliderWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return {...sliderWidgetDefaultSettings};
}
protected onSettingsSet(settings: WidgetSettings) {
this.sliderWidgetSettingsForm = this.fb.group({
initialState: [settings.initialState, []],
valueChange: [settings.valueChange, []],
disabledState: [settings.disabledState, []],
layout: [settings.layout, []],
autoScale: [settings.autoScale, []],
showValue: [settings.showValue, []],
valueUnits: [settings.valueUnits, []],
valueDecimals: [settings.valueDecimals, []],
valueFont: [settings.valueFont, []],
valueColor: [settings.valueColor, []],
tickMin: [settings.tickMin, []],
tickMax: [settings.tickMax, []],
showTicks: [settings.showTicks, []],
ticksFont: [settings.ticksFont, []],
ticksColor: [settings.ticksColor, []],
showTickMarks: [settings.showTickMarks, []],
tickMarksCount: [settings.tickMarksCount, [Validators.min(2)]],
tickMarksColor: [settings.tickMarksColor, []],
mainColor: [settings.mainColor, []],
backgroundColor: [settings.backgroundColor, []],
mainColorDisabled: [settings.mainColorDisabled, []],
backgroundColorDisabled: [settings.backgroundColorDisabled, []],
leftIconSize: [settings.leftIconSize, [Validators.min(0)]],
leftIconSizeUnit: [settings.leftIconSizeUnit, []],
leftIcon: [settings.leftIcon, []],
leftIconColor: [settings.leftIconColor, []],
rightIconSize: [settings.rightIconSize, [Validators.min(0)]],
rightIconSizeUnit: [settings.rightIconSizeUnit, []],
rightIcon: [settings.rightIcon, []],
rightIconColor: [settings.rightIconColor, []],
background: [settings.background, []],
});
}
protected validatorTriggers(): string[] {
return ['showValue', 'showTicks', 'showTickMarks', 'layout'];
}
protected updateValidators(_emitEvent: boolean): void {
const showValue: boolean = this.sliderWidgetSettingsForm.get('showValue').value;
const showTicks: boolean = this.sliderWidgetSettingsForm.get('showTicks').value;
const showTickMarks: boolean = this.sliderWidgetSettingsForm.get('showTickMarks').value;
const layout: SliderLayout = this.sliderWidgetSettingsForm.get('layout').value;
const valueEnabled = layout !== SliderLayout.simplified;
const leftRightIconsEnabled = layout === SliderLayout.extended;
if (valueEnabled && showValue) {
this.sliderWidgetSettingsForm.get('valueUnits').enable();
this.sliderWidgetSettingsForm.get('valueDecimals').enable();
this.sliderWidgetSettingsForm.get('valueFont').enable();
this.sliderWidgetSettingsForm.get('valueColor').enable();
} else {
this.sliderWidgetSettingsForm.get('valueUnits').disable();
this.sliderWidgetSettingsForm.get('valueDecimals').disable();
this.sliderWidgetSettingsForm.get('valueFont').disable();
this.sliderWidgetSettingsForm.get('valueColor').disable();
}
if (showTicks) {
this.sliderWidgetSettingsForm.get('ticksFont').enable();
this.sliderWidgetSettingsForm.get('ticksColor').enable();
} else {
this.sliderWidgetSettingsForm.get('ticksFont').disable();
this.sliderWidgetSettingsForm.get('ticksColor').disable();
}
if (showTickMarks) {
this.sliderWidgetSettingsForm.get('tickMarksCount').enable();
this.sliderWidgetSettingsForm.get('tickMarksColor').enable();
} else {
this.sliderWidgetSettingsForm.get('tickMarksCount').disable();
this.sliderWidgetSettingsForm.get('tickMarksColor').disable();
}
if (leftRightIconsEnabled) {
this.sliderWidgetSettingsForm.get('leftIconSize').enable();
this.sliderWidgetSettingsForm.get('leftIconSizeUnit').enable();
this.sliderWidgetSettingsForm.get('leftIcon').enable();
this.sliderWidgetSettingsForm.get('leftIconColor').enable();
this.sliderWidgetSettingsForm.get('rightIconSize').enable();
this.sliderWidgetSettingsForm.get('rightIconSizeUnit').enable();
this.sliderWidgetSettingsForm.get('rightIcon').enable();
this.sliderWidgetSettingsForm.get('rightIconColor').enable();
} else {
this.sliderWidgetSettingsForm.get('leftIconSize').disable();
this.sliderWidgetSettingsForm.get('leftIconSizeUnit').disable();
this.sliderWidgetSettingsForm.get('leftIcon').disable();
this.sliderWidgetSettingsForm.get('leftIconColor').disable();
this.sliderWidgetSettingsForm.get('rightIconSize').disable();
this.sliderWidgetSettingsForm.get('rightIconSizeUnit').disable();
this.sliderWidgetSettingsForm.get('rightIcon').disable();
this.sliderWidgetSettingsForm.get('rightIconColor').disable();
}
}
private _valuePreviewFn(): string {
const units: string = this.sliderWidgetSettingsForm.get('valueUnits').value;
const decimals: number = this.sliderWidgetSettingsForm.get('valueDecimals').value;
return formatValue(48, decimals, units, false);
}
}

46
ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.html

@ -19,8 +19,8 @@
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-panel">
<div fxFlex fxLayout="column" class="tb-form-row space-between">
<div fxFlex fxLayout="row" style="width: 100%;" fxLayoutAlign="space-between center">
<div class="fixed-title-width" translate>widgets.liquid-level-card.shape</div>
<div class="tb-flex row space-between align-center no-gap fill-width">
<div class="fixed-title-width" translate>widgets.liquid-level-card.shape</div>
<tb-toggle-select formControlName="tankSelectionType">
<tb-toggle-option *ngFor="let type of DataSourceTypes" [value]="type">
{{ DataSourceTypeTranslations.get(type) | translate }}
@ -51,7 +51,7 @@
formControlName="shapeAttributeName">
</tb-string-autocomplete>
</div>
<div fxFlex fxLayout="row" fxLayoutAlign="space-between center" style="width: 100%;">
<div class="tb-flex row space-between align-center no-gap fill-width">
<div class="tb-hint" style="padding: 0;" translate>widgets.liquid-level-card.shape-by-attribute</div>
<div class="see-example"
[tb-help-popup]="'widget/lib/indicator/shape_attribute_fn'"
@ -87,9 +87,9 @@
formControlName="datasourceUnits">
</tb-unit-input>
</div>
<div class="tb-form-row space-between" *ngIf="levelCardWidgetSettingsForm.get('layout').value === LevelCardLayout.absolute">
<div class="tb-form-row space-between column-xs" *ngIf="levelCardWidgetSettingsForm.get('layout').value === LevelCardLayout.absolute">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.widget-units</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="widgetUnitsSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -111,9 +111,9 @@
</ng-template>
</div>
</div>
<div class="tb-form-row" *ngIf="volumeInput">
<div class="tb-form-row column-xs" *ngIf="volumeInput">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.total-volume</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="volumeSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -141,10 +141,30 @@
formControlName="volumeAttributeName">
</tb-string-autocomplete>
</ng-template>
<tb-unit-input [tagFilter]="unitsType.capacity"
required style="max-width: 25%" class="flex"
</div>
</div>
<div class="tb-form-row space-between column-xs" [fxShow]="volumeInput">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.total-volume-units</div>
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="volumeUnitsSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
{{ DataSourceTypeTranslations.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-unit-input *ngIf="levelCardWidgetSettingsForm.get('volumeUnitsSource').value === DataSourceType.static; else selectVolumeUnitsAttributes"
class="flex" required
[tagFilter]="unitsType.capacity"
formControlName="volumeUnits">
</tb-unit-input>
<ng-template #selectVolumeUnitsAttributes>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
required style="flex: 1"
[errorText]="'widgets.liquid-level-card.attribute-name-required' | translate"
formControlName="volumeUnitsAttributeName">
</tb-string-autocomplete>
</ng-template>
</div>
</div>
</div>
@ -168,7 +188,7 @@
<div class="tb-form-row space-between"
*ngIf="levelCardWidgetSettingsForm.get('layout').value !== LevelCardLayout.simple">
<div class="fixed-title-width" translate>widgets.liquid-level-card.value</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<tb-font-settings formControlName="valueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
@ -178,7 +198,7 @@
</div>
<div class="tb-form-row" *ngIf="levelCardWidgetSettingsForm.get('layout').value === LevelCardLayout.absolute">
<div class="fixed-title-width" translate>widgets.liquid-level-card.total-volume</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<tb-font-settings formControlName="volumeFont"
[previewText]="totalVolumeValuePreviewFn">
</tb-font-settings>
@ -212,7 +232,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTooltipLevel">
{{ 'widgets.liquid-level-card.level' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<tb-unit-input [tagFilter]="unitsType.capacity"
required class="flex" formControlName="tooltipUnits">
</tb-unit-input>
@ -232,7 +252,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTooltipDate">
{{ 'widgets.value-card.date' | translate }}
</mat-slide-toggle>
<div fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<tb-date-format-select fxFlex formControlName="tooltipDateFormat"></tb-date-format-select>
<tb-font-settings formControlName="tooltipDateFont"
[previewText]="datePreviewFn">

4
ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts

@ -158,7 +158,9 @@ export class LiquidLevelCardWidgetSettingsComponent extends WidgetSettingsCompon
volumeSource: [settings.volumeSource, []],
volumeConstant: [settings.volumeConstant, [Validators.required, Validators.min(0.1)]],
volumeAttributeName: [settings.volumeAttributeName, [Validators.required]],
volumeUnitsSource: [settings.volumeUnitsSource, []],
volumeUnits: [settings.volumeUnits, [Validators.required]],
volumeUnitsAttributeName: [settings.volumeUnitsAttributeName, [Validators.required]],
volumeFont: [settings.volumeFont, []],
volumeColor: [settings.volumeColor, []],
valueFont: [settings.valueFont, []],
@ -195,7 +197,7 @@ export class LiquidLevelCardWidgetSettingsComponent extends WidgetSettingsCompon
protected validatorTriggers(): string[] {
return [
'showBackgroundOverlay', 'showTooltip', 'showTooltipLevel', 'tankSelectionType', 'datasourceUnits',
'showTooltipDate', 'layout', 'volumeSource', 'widgetUnitsSource'
'showTooltipDate', 'layout', 'volumeSource', 'widgetUnitsSource', 'volumeUnitsSource'
];
}

18
ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts

@ -321,6 +321,12 @@ import {
import {
CommandButtonWidgetSettingsComponent
} from '@home/components/widget/lib/settings/button/command-button-widget-settings.component';
import {
PowerButtonWidgetSettingsComponent
} from '@home/components/widget/lib/settings/button/power-button-widget-settings.component';
import {
SliderWidgetSettingsComponent
} from '@home/components/widget/lib/settings/control/slider-widget-settings.component';
@NgModule({
declarations: [
@ -436,7 +442,9 @@ import {
BarChartWithLabelsWidgetSettingsComponent,
SingleSwitchWidgetSettingsComponent,
ActionButtonWidgetSettingsComponent,
CommandButtonWidgetSettingsComponent
CommandButtonWidgetSettingsComponent,
PowerButtonWidgetSettingsComponent,
SliderWidgetSettingsComponent
],
imports: [
CommonModule,
@ -557,7 +565,9 @@ import {
BarChartWithLabelsWidgetSettingsComponent,
SingleSwitchWidgetSettingsComponent,
ActionButtonWidgetSettingsComponent,
CommandButtonWidgetSettingsComponent
CommandButtonWidgetSettingsComponent,
PowerButtonWidgetSettingsComponent,
SliderWidgetSettingsComponent
]
})
export class WidgetSettingsModule {
@ -645,5 +655,7 @@ export const widgetSettingsComponentsMap: {[key: string]: Type<IWidgetSettingsCo
'tb-bar-chart-with-labels-widget-settings': BarChartWithLabelsWidgetSettingsComponent,
'tb-single-switch-widget-settings': SingleSwitchWidgetSettingsComponent,
'tb-action-button-widget-settings': ActionButtonWidgetSettingsComponent,
'tb-command-button-widget-settings': CommandButtonWidgetSettingsComponent
'tb-command-button-widget-settings': CommandButtonWidgetSettingsComponent,
'tb-power-button-widget-settings': PowerButtonWidgetSettingsComponent,
'tb-slider-widget-settings': SliderWidgetSettingsComponent
};

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

Loading…
Cancel
Save