diff --git a/application/src/main/data/json/system/widget_bundles/control_widgets.json b/application/src/main/data/json/system/widget_bundles/control_widgets.json index f8395c656f..f440feb385 100644 --- a/application/src/main/data/json/system/widget_bundles/control_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/control_widgets.json @@ -45,9 +45,9 @@ "sizeX": 5, "sizeY": 4.5, "resources": [], - "templateHtml": "", + "templateHtml": "", "templateCss": "", - "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n scope.ctx = self.ctx;\n}\n\nself.onResize = function() {\n if (self.ctx.resize) {\n self.ctx.resize();\n }\n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"minValue\": {\n \"title\": \"Minimum value\",\n \"type\": \"number\",\n \"default\": 0\n },\n \"maxValue\": {\n \"title\": \"Maximum value\",\n \"type\": \"number\",\n \"default\": 100\n },\n \"initialValue\": {\n \"title\": \"Initial value\",\n \"type\": \"number\",\n \"default\": 50\n },\n \"title\": {\n \"title\": \"Knob title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"getValueMethod\": {\n \"title\": \"Get value method\",\n \"type\": \"string\",\n \"default\": \"getValue\"\n },\n \"setValueMethod\": {\n \"title\": \"Set value method\",\n \"type\": \"string\",\n \"default\": \"setValue\"\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n }\n },\n \"required\": [\"minValue\", \"maxValue\", \"getValueMethod\", \"setValueMethod\", \"requestTimeout\"]\n },\n \"form\": [\n \"minValue\",\n \"maxValue\",\n \"initialValue\",\n \"title\",\n \"getValueMethod\",\n \"setValueMethod\",\n \"requestTimeout\"\n ]\n}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"maxValue\":100,\"initialValue\":50,\"minValue\":0,\"title\":\"Knob control\",\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\"},\"title\":\"Knob Control\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}" @@ -61,9 +61,9 @@ "sizeX": 4, "sizeY": 2.5, "resources": [], - "templateHtml": "", + "templateHtml": "", "templateCss": "", - "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n scope.ctx = self.ctx;\n}\n\nself.onResize = function() {\n if (self.ctx.resize) {\n self.ctx.resize();\n }\n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"initialValue\": {\n \"title\": \"Initial value\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"title\": {\n \"title\": \"Switch title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"showOnOffLabels\": {\n \"title\": \"Show on/off labels\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"retrieveValueMethod\": {\n \"title\": \"Retrieve on/off value using method\",\n \"type\": \"string\",\n \"default\": \"rpc\"\n },\n \"valueKey\": {\n \"title\": \"Attribute/Timeseries value key (only when subscribe for attribute/timeseries method)\",\n \"type\": \"string\",\n \"default\": \"value\"\n },\n \"getValueMethod\": {\n \"title\": \"RPC get value method\",\n \"type\": \"string\",\n \"default\": \"getValue\"\n },\n \"setValueMethod\": {\n \"title\": \"RPC set value method\",\n \"type\": \"string\",\n \"default\": \"setValue\"\n },\n \"parseValueFunction\": {\n \"title\": \"Parse value function, f(data), returns boolean\",\n \"type\": \"string\",\n \"default\": \"return data ? true : false;\"\n },\n \"convertValueFunction\": {\n \"title\": \"Convert value function, f(value), returns payload used by RPC set value method\",\n \"type\": \"string\",\n \"default\": \"return value;\"\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n }\n },\n \"required\": [\"requestTimeout\"]\n },\n \"form\": [\n \"initialValue\",\n \"title\",\n \"showOnOffLabels\",\n {\n \"key\": \"retrieveValueMethod\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"none\",\n \"label\": \"Don't retrieve\"\n },\n {\n \"value\": \"rpc\",\n \"label\": \"Call RPC get value method\"\n },\n {\n \"value\": \"attribute\",\n \"label\": \"Subscribe for attribute\"\n },\n {\n \"value\": \"timeseries\",\n \"label\": \"Subscribe for timeseries\"\n }\n ]\n },\n \"valueKey\",\n \"getValueMethod\",\n \"setValueMethod\",\n {\n \"key\": \"parseValueFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"convertValueFunction\",\n \"type\": \"javascript\"\n },\n \"requestTimeout\"\n ]\n}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":false,\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\",\"showOnOffLabels\":true,\"title\":\"Switch control\"},\"title\":\"Switch Control\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}" @@ -77,9 +77,9 @@ "sizeX": 2.5, "sizeY": 2, "resources": [], - "templateHtml": "", + "templateHtml": "", "templateCss": "", - "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n scope.ctx = self.ctx;\n}\n\nself.onResize = function() {\n if (self.ctx.resize) {\n self.ctx.resize();\n }\n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"initialValue\": {\n \"title\": \"Initial value\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"title\": {\n \"title\": \"Switch title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"retrieveValueMethod\": {\n \"title\": \"Retrieve on/off value using method\",\n \"type\": \"string\",\n \"default\": \"rpc\"\n },\n \"valueKey\": {\n \"title\": \"Attribute/Timeseries value key (only when subscribe for attribute/timeseries method)\",\n \"type\": \"string\",\n \"default\": \"value\"\n },\n \"getValueMethod\": {\n \"title\": \"RPC get value method\",\n \"type\": \"string\",\n \"default\": \"getValue\"\n },\n \"setValueMethod\": {\n \"title\": \"RPC set value method\",\n \"type\": \"string\",\n \"default\": \"setValue\"\n },\n \"parseValueFunction\": {\n \"title\": \"Parse value function, f(data), returns boolean\",\n \"type\": \"string\",\n \"default\": \"return data ? true : false;\"\n },\n \"convertValueFunction\": {\n \"title\": \"Convert value function, f(value), returns payload used by RPC set value method\",\n \"type\": \"string\",\n \"default\": \"return value;\"\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n }\n },\n \"required\": [\"requestTimeout\"]\n },\n \"form\": [\n \"initialValue\",\n \"title\",\n {\n \"key\": \"retrieveValueMethod\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"none\",\n \"label\": \"Don't retrieve\"\n },\n {\n \"value\": \"rpc\",\n \"label\": \"Call RPC get value method\"\n },\n {\n \"value\": \"attribute\",\n \"label\": \"Subscribe for attribute\"\n },\n {\n \"value\": \"timeseries\",\n \"label\": \"Subscribe for timeseries\"\n }\n ]\n },\n \"valueKey\",\n \"getValueMethod\",\n \"setValueMethod\",\n {\n \"key\": \"parseValueFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"convertValueFunction\",\n \"type\": \"javascript\"\n },\n \"requestTimeout\"\n ]\n}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":false,\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\",\"title\":\"Round switch\",\"retrieveValueMethod\":\"rpc\",\"valueKey\":\"value\",\"parseValueFunction\":\"return data ? true : false;\",\"convertValueFunction\":\"return value;\"},\"title\":\"Round switch\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}" @@ -93,9 +93,9 @@ "sizeX": 2.5, "sizeY": 2.5, "resources": [], - "templateHtml": "", + "templateHtml": "", "templateCss": "", - "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n scope.ctx = self.ctx;\n}\n\nself.onResize = function() {\n if (self.ctx.resize) {\n self.ctx.resize();\n }\n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"initialValue\": {\n \"title\": \"Initial value\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"title\": {\n \"title\": \"LED title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"ledColor\": {\n \"title\": \"LED Color\",\n \"type\": \"string\",\n \"default\": \"green\"\n },\n \"performCheckStatus\": {\n \"title\": \"Perform RPC device status check\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"checkStatusMethod\": {\n \"title\": \"RPC check device status method\",\n \"type\": \"string\",\n \"default\": \"checkStatus\"\n },\n \"retrieveValueMethod\": {\n \"title\": \"Retrieve led status value using method\",\n \"type\": \"string\",\n \"default\": \"attribute\"\n },\n \"valueAttribute\": {\n \"title\": \"Device attribute/timeseries containing led status value\",\n \"type\": \"string\",\n \"default\": \"value\"\n },\n \"parseValueFunction\": {\n \"title\": \"Parse led status value function, f(data), returns boolean\",\n \"type\": \"string\",\n \"default\": \"return data ? true : false;\"\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout (ms)\",\n \"type\": \"number\",\n \"default\": 500\n }\n },\n \"required\": [\"valueAttribute\", \"requestTimeout\"]\n },\n \"form\": [\n \"initialValue\",\n \"title\",\n {\n \"key\": \"ledColor\",\n \"type\": \"color\"\n },\n \"performCheckStatus\",\n \"checkStatusMethod\",\n {\n \"key\": \"retrieveValueMethod\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"attribute\",\n \"label\": \"Subscribe for attribute\"\n },\n {\n \"value\": \"timeseries\",\n \"label\": \"Subscribe for timeseries\"\n }\n ]\n },\n \"valueAttribute\",\n {\n \"key\": \"parseValueFunction\",\n \"type\": \"javascript\"\n },\n \"requestTimeout\"\n ]\n}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":true,\"title\":\"Led indicator\",\"ledColor\":\"#4caf50\",\"valueAttribute\":\"value\",\"retrieveValueMethod\":\"attribute\",\"parseValueFunction\":\"return data ? true : false;\",\"performCheckStatus\":true,\"checkStatusMethod\":\"checkStatus\"},\"title\":\"Led indicator\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}" diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 00d33cdde1..88f5139e1e 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -185,7 +185,7 @@ export interface WidgetSubscriptionCallbacks { } export interface WidgetSubscriptionOptions { - type: widgetType; + type?: widgetType; stateData?: boolean; alarmSource?: Datasource; alarmSearchStatus?: AlarmSearchStatus; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/canvas-digital-gauge.ts b/ui-ngx/src/app/modules/home/components/widget/lib/canvas-digital-gauge.ts index b1fdd7b859..336dc7de2f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/canvas-digital-gauge.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/canvas-digital-gauge.ts @@ -395,10 +395,12 @@ export class CanvasDigitalGauge extends BaseGauge { let color = this.contextProgressClone.currentColor; const options = this.options as CanvasDigitalGaugeOptions; if (!color) { + const progress = (CanvasGauges.drawings.normalizedValue(options).normal - options.minValue) / + (options.maxValue - options.minValue); if (options.neonGlowBrightness) { - color = getProgressColor(0, options.neonColorsRange); + color = getProgressColor(progress, options.neonColorsRange); } else { - color = getProgressColor(0, options.colorsRange); + color = getProgressColor(progress, options.colorsRange); } } return color; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html new file mode 100644 index 0000000000..5b70d8a958 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html @@ -0,0 +1,45 @@ + +
+
+
+
+ {{ value }} +
+
+ +
+
+
+
+
+ {{ error }} +
+
+ {{ title }} +
+
+ min + + max +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.scss new file mode 100644 index 0000000000..ca37ac4c69 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.scss @@ -0,0 +1,168 @@ +/** + * Copyright © 2016-2019 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. + */ +$knob-img: url("./svg/knob.svg") !default; + +$bars-margin-pct: percentage(.033) !default; +$background-margin-pct: percentage(.05) !default; +$value-container-margin-pct: percentage(.35) !default; +$error-height: percentage(.05) !default; +$title-height: percentage(.066) !default; +$title-container-margin-pct: percentage(.2) !default; +$title-container-margin-bottom-pct: percentage(.05) !default; +$minmax-height: percentage(.04) !default; +$minmax-container-margin-pct: percentage(.18) !default; +$minmax-container-margin-bottom-pct: percentage(.12) !default; + +$background-color: #e6e7e8 !default; + +:host { + .tb-knob { + width: 100%; + height: 100%; + background: $background-color; + + .knob { + position: relative; + + &[draggable] { + user-select: none; + } + + #canvasBar { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + } + + .canvas-background { + position: absolute; + top: $background-margin-pct; + right: $background-margin-pct; + bottom: $background-margin-pct; + left: $background-margin-pct; + z-index: 2; + background: #3f4346; + border-radius: 50%; + } + + .value-container { + position: absolute; + top: $value-container-margin-pct; + right: $value-container-margin-pct; + bottom: $value-container-margin-pct; + left: $value-container-margin-pct; + z-index: 4; + + .knob-value { + font-weight: 500; + color: #fff; + white-space: nowrap; + } + } + + .error-container { + position: absolute; + top: 1%; + right: 0; + left: 0; + z-index: 4; + height: $error-height; + + .knob-error { + color: #ff3315; + white-space: nowrap; + } + } + + .title-container { + position: absolute; + right: $title-container-margin-pct; + bottom: $title-container-margin-bottom-pct; + left: $title-container-margin-pct; + z-index: 4; + height: $title-height; + + .knob-title { + font-weight: 500; + color: #757575; + white-space: nowrap; + } + } + + .minmax-container { + position: absolute; + right: $minmax-container-margin-pct; + bottom: $minmax-container-margin-bottom-pct; + left: $minmax-container-margin-pct; + z-index: 4; + height: $minmax-height; + + .minmax-label { + font-weight: 500; + color: #757575; + white-space: nowrap; + } + } + + .top-pointer-container { + position: absolute; + top: $bars-margin-pct; + right: $bars-margin-pct; + bottom: $bars-margin-pct; + left: $bars-margin-pct; + z-index: 3; + cursor: pointer !important; + + .top-pointer { + position: absolute; + top: 50%; + left: 22%; + width: 5%; + height: 5%; + margin-top: -2.5%; + cursor: pointer !important; + content: ""; + background-color: #b5b5b5; + border-radius: 50%; + box-shadow: 1px 0 2px #040404; + } + } + + .top { + position: absolute; + top: $bars-margin-pct; + right: $bars-margin-pct; + bottom: $bars-margin-pct; + left: $bars-margin-pct; + z-index: 2; + cursor: pointer !important; + background: $knob-img no-repeat; + background-size: contain; + } + + #text-measure { + position: absolute; + width: auto; + height: auto; + white-space: nowrap; + visibility: hidden; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts new file mode 100644 index 0000000000..4c21f188b0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts @@ -0,0 +1,435 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { UtilsService } from '@core/services/utils.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { isDefined, isNumber } from '@core/utils'; +import { CanvasDigitalGauge, CanvasDigitalGaugeOptions } from '@home/components/widget/lib/canvas-digital-gauge'; +import GenericOptions = CanvasGauges.GenericOptions; +import * as tinycolor_ from 'tinycolor2'; + +const tinycolor = tinycolor_; + +interface KnobSettings { + minValue: number; + maxValue: number; + initialValue: number; + title: string; + getValueMethod: string; + setValueMethod: string; + requestTimeout: number; +} + +@Component({ + selector: 'tb-knob', + templateUrl: './knob.component.html', + styleUrls: ['./knob.component.scss'] +}) +export class KnobComponent extends PageComponent implements OnInit, OnDestroy { + + @ViewChild('knob', {static: true}) knobRef: ElementRef; + @ViewChild('knobContainer', {static: true}) knobContainerRef: ElementRef; + @ViewChild('knobTopPointerContainer', {static: true}) knobTopPointerContainerRef: ElementRef; + @ViewChild('knobTopPointer', {static: true}) knobTopPointerRef: ElementRef; + @ViewChild('knobValueContainer', {static: true}) knobValueContainerRef: ElementRef; + @ViewChild('knobValue', {static: true}) knobValueRef: ElementRef; + @ViewChild('knobErrorContainer', {static: true}) knobErrorContainerRef: ElementRef; + @ViewChild('knobError', {static: true}) knobErrorRef: ElementRef; + @ViewChild('knobTitleContainer', {static: true}) knobTitleContainerRef: ElementRef; + @ViewChild('knobTitle', {static: true}) knobTitleRef: ElementRef; + @ViewChild('knobMinmaxContainer', {static: true}) knobMinmaxContainerRef: ElementRef; + @ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef; + @ViewChild('canvasBar', {static: true}) canvasBarElementRef: ElementRef; + + @Input() + ctx: WidgetContext; + + value = '0'; + error = ''; + title = ''; + minValue: number; + maxValue: number; + + private startDeg = -1; + private currentDeg = 0; + private rotation = 0; + private lastDeg = 0; + private moving = false; + + private minDeg = -45; + private maxDeg = 225; + + private isSimulated: boolean; + private requestTimeout: number; + private getValueMethod: string; + private setValueMethod: string; + + private executingUpdateValue: boolean; + private scheduledValue: number; + private rpcValue: number; + + private knob: JQuery; + private knobContainer: JQuery; + private knobTopPointerContainer: JQuery; + private knobTopPointer: JQuery; + private knobValueContainer: JQuery; + private knobValue: JQuery; + private knobTitleContainer: JQuery; + private knobTitle: JQuery; + private knobErrorContainer: JQuery; + private knobError: JQuery; + private knobMinmaxContainer: JQuery; + private minmaxLabel: JQuery; + private textMeasure: JQuery; + private canvasBarElement: HTMLElement; + + private canvasBar: CanvasDigitalGauge; + + private knobResizeListener: any; + + constructor(private utils: UtilsService, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.knob = $(this.knobRef.nativeElement); + this.knobContainer = $(this.knobContainerRef.nativeElement); + this.knobTopPointerContainer = $(this.knobTopPointerContainerRef.nativeElement); + this.knobTopPointer = $(this.knobTopPointerRef.nativeElement); + this.knobValueContainer = $(this.knobValueContainerRef.nativeElement); + this.knobValue = $(this.knobValueRef.nativeElement); + this.knobTitleContainer = $(this.knobTitleContainerRef.nativeElement); + this.knobTitle = $(this.knobTitleRef.nativeElement); + this.knobErrorContainer = $(this.knobErrorContainerRef.nativeElement); + this.knobError = $(this.knobErrorRef.nativeElement); + this.knobMinmaxContainer = $(this.knobMinmaxContainerRef.nativeElement); + this.minmaxLabel = this.knobMinmaxContainer.find('.minmax-label'); + this.textMeasure = $(this.textMeasureRef.nativeElement); + this.canvasBarElement = this.canvasBarElementRef.nativeElement; + + this.knobResizeListener = this.resize.bind(this); + // @ts-ignore + addResizeListener(this.knobContainerRef.nativeElement, this.knobResizeListener); + this.init(); + } + + ngOnDestroy(): void { + if (this.knobResizeListener) { + // @ts-ignore + removeResizeListener(this.knobContainerRef.nativeElement, this.knobResizeListener); + } + } + + private init() { + const settings: KnobSettings = this.ctx.settings; + + this.minValue = isDefined(settings.minValue) ? settings.minValue : 0; + this.maxValue = isDefined(settings.maxValue) ? settings.maxValue : 100; + this.title = isDefined(settings.title) ? settings.title : ''; + + const levelColors = ['#19ff4b', '#ffff19', '#ff3232']; + + const canvasBarData: CanvasDigitalGaugeOptions = { + renderTo: this.canvasBarElement, + hideValue: true, + neonGlowBrightness: 0, + gaugeWidthScale: 0.4, + gaugeColor: 'rgba(0, 0, 0, 0)', + levelColors, + minValue: this.minValue, + maxValue: this.maxValue, + gaugeType: 'donut', + dashThickness: 2, + donutStartAngle: 3/4*Math.PI, + donutEndAngle: 9/4*Math.PI, + animation: false + }; + + this.canvasBar = new CanvasDigitalGauge(canvasBarData).draw(); + + this.knob.on('click', (e) => { + if (this.moving) { + this.moving = false; + return false; + } + e.preventDefault(); + + const offset = this.knob.offset(); + const center = { + y : offset.top + this.knob.height()/2, + x: offset.left + this.knob.width()/2 + }; + const rad2deg = 180/Math.PI; + const t: Touch = ((e.originalEvent as any).touches) ? (e.originalEvent as any).touches[0] : e; + + const a = center.y - t.pageY; + const b = center.x - t.pageX; + let deg = Math.atan2(a,b)*rad2deg; + if(deg < 0){ + deg = 360 + deg; + } + if (deg > this.maxDeg) { + if (deg - 360 > this.minDeg) { + deg = deg - 360; + } else { + return false; + } + } + this.currentDeg = deg; + this.lastDeg = deg; + this.knobTopPointerContainer.css('transform','rotate('+(this.currentDeg)+'deg)'); + this.turn(this.degreeToRatio(this.currentDeg)); + this.rotation = this.currentDeg; + this.startDeg = -1; + }); + + this.knob.on('mousedown touchstart', (e) => { + e.preventDefault(); + const offset = this.knob.offset(); + const center = { + y : offset.top + this.knob.height()/2, + x: offset.left + this.knob.width()/2 + }; + const rad2deg = 180/Math.PI; + + this.knob.on('mousemove.rem touchmove.rem', (ev) => { + this.moving = true; + const t: Touch = ((ev.originalEvent as any).touches) ? (ev.originalEvent as any).touches[0] : ev; + + const a = center.y - t.pageY; + const b = center.x - t.pageX; + let deg = Math.atan2(a,b)*rad2deg; + if(deg < 0){ + deg = 360 + deg; + } + + if(this.startDeg === -1){ + this.startDeg = deg; + } + + let tmp = Math.floor((deg-this.startDeg) + this.rotation); + + if(tmp < 0){ + tmp = 360 + tmp; + } + else if(tmp > 359){ + tmp = tmp % 360; + } + + if (tmp > this.maxDeg) { + if (tmp - 360 > this.minDeg) { + tmp = tmp - 360; + } else { + const deltaMax = Math.abs(this.maxDeg - this.lastDeg); + const deltaMin = Math.abs(this.minDeg - this.lastDeg); + if (deltaMax < deltaMin) { + tmp = this.maxDeg; + } else { + tmp = this.minDeg; + } + } + } + if(Math.abs(tmp - this.lastDeg) > 180){ + this.startDeg = deg; + this.rotation = this.currentDeg; + return false; + } + + this.currentDeg = tmp; + this.lastDeg = tmp; + + this.knobTopPointerContainer.css('transform','rotate('+(this.currentDeg)+'deg)'); + this.turn(this.degreeToRatio(this.currentDeg)); + }); + + $(document).on('mouseup.rem touchend.rem',() => { + this.knob.off('.rem'); + $(document).off('.rem'); + this.rotation = this.currentDeg; + this.startDeg = -1; + }); + + }); + + const initialValue = isDefined(settings.initialValue) ? settings.initialValue : this.minValue; + this.setValue(initialValue); + + const subscription = this.ctx.defaultSubscription; + const rpcEnabled = subscription.rpcEnabled; + + this.isSimulated = this.utils.widgetEditMode; + + this.requestTimeout = 500; + if (settings.requestTimeout) { + this.requestTimeout = settings.requestTimeout; + } + this.getValueMethod = 'getValue'; + if (settings.getValueMethod && settings.getValueMethod.length) { + this.getValueMethod = settings.getValueMethod; + } + this.setValueMethod = 'setValue'; + if (settings.setValueMethod && settings.setValueMethod.length) { + this.setValueMethod = settings.setValueMethod; + } + if (!rpcEnabled) { + this.onError('Target device is not set!'); + } else { + if (!this.isSimulated) { + this.rpcRequestValue(); + } + } + } + + private degreeToRatio(degree: number): number { + return (degree-this.minDeg)/(this.maxDeg-this.minDeg); + } + + private ratioToDegree(ratio: number): number { + return this.minDeg + ratio*(this.maxDeg-this.minDeg); + } + + private turn(ratio: number) { + const value = Number((this.minValue + (this.maxValue - this.minValue)*ratio).toFixed(this.ctx.decimals)); + if (this.canvasBar.value !== value) { + this.canvasBar.value = value; + } + this.updateColor(this.canvasBar.getValueColor()); + this.onValue(value); + } + + private resize() { + const width = this.knobContainer.width(); + const height = this.knobContainer.height(); + const size = Math.min(width, height); + this.knob.css({width: size, height: size}); + this.canvasBar.update({width: size, height: size} as GenericOptions); + this.setFontSize(this.knobTitle, this.title, this.knobTitleContainer.height(), this.knobTitleContainer.width()); + this.setFontSize(this.knobError, this.error, this.knobErrorContainer.height(), this.knobErrorContainer.width()); + const minmaxHeight = this.knobMinmaxContainer.height(); + this.minmaxLabel.css({fontSize: minmaxHeight+'px', lineHeight: minmaxHeight+'px'}); + this.checkValueSize(); + } + + private checkValueSize() { + const fontSize = this.knobValueContainer.height()/3.3; + const containerWidth = this.knobValueContainer.width(); + this.setFontSize(this.knobValue, this.value+'', fontSize, containerWidth); + } + + private setFontSize(element: JQuery, text: string, fontSize: number, maxWidth: number) { + let textWidth = this.measureTextWidth(text, fontSize); + while (textWidth > maxWidth) { + fontSize--; + textWidth = this.measureTextWidth(text, fontSize); + } + element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + } + + private measureTextWidth(text: string, fontSize: number): number { + this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + this.textMeasure.text(text); + return this.textMeasure.width(); + } + + private setValue(value: number) { + const ratio = (value-this.minValue) / (this.maxValue - this.minValue); + this.rotation = this.lastDeg = this.currentDeg = this.ratioToDegree(ratio); + this.knobTopPointerContainer.css('transform','rotate('+(this.currentDeg)+'deg)'); + if (this.canvasBar.value !== value) { + this.canvasBar.value = value; + } + this.updateColor(this.canvasBar.getValueColor()); + this.value = this.formatValue(value); + this.checkValueSize(); + } + + private updateColor(color: string) { + const glowColor = tinycolor(color).brighten(30).toHexString(); + this.knobValue.css({color: glowColor}); + const textShadow = `${color} 1px 1px 10px, ${glowColor} 1px 1px 10px`; + this.knobValue.css({textShadow}); + this.knobTopPointer.css({backgroundColor: glowColor}); + const boxShadow = `inset 1px 0 2px #040404, 1px 1px 8px 2px ${glowColor}`; + this.knobTopPointer.css({boxShadow}); + } + + private onValue(value: number) { + this.value = this.formatValue(value); + this.checkValueSize(); + this.rpcUpdateValue(value); + this.ctx.detectChanges(); + } + + private formatValue(value: any): string { + return this.ctx.utils.formatValue(value, this.ctx.decimals, this.ctx.units, true); + } + + private rpcRequestValue() { + this.error = ''; + this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe( + (responseBody) => { + if (isNumber(responseBody)) { + const numValue = Number(Number(responseBody).toFixed(this.ctx.decimals)); + this.setValue(numValue); + } else { + const errorText = `Unable to parse response: ${responseBody}`; + this.onError(errorText); + } + }, + () => { + const errorText = this.ctx.defaultSubscription.rpcErrorText; + this.onError(errorText); + } + ); + } + + private rpcUpdateValue(value: number) { + if (this.executingUpdateValue) { + this.scheduledValue = value; + return; + } else { + this.scheduledValue = null; + this.rpcValue = value; + this.executingUpdateValue = true; + } + this.error = ''; + this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, value, this.requestTimeout).subscribe( + () => { + this.executingUpdateValue = false; + if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) { + this.rpcUpdateValue(this.scheduledValue); + } + }, + () => { + this.executingUpdateValue = false; + const errorText = this.ctx.defaultSubscription.rpcErrorText; + this.onError(errorText); + } + ); + } + + private onError(error: string) { + this.error = error; + this.setFontSize(this.knobError, this.error, this.knobErrorContainer.height(), this.knobErrorContainer.width()); + this.ctx.detectChanges(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.html new file mode 100644 index 0000000000..5d15434b74 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.html @@ -0,0 +1,32 @@ + +
+
+ {{title}} +
+
+
+
+
+
+ {{ error }} +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.scss new file mode 100644 index 0000000000..5dc4e28814 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.scss @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2019 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 "~compass-sass-mixins/lib/compass"; + +$error-height: 14px !default; + +$background-color: #e6e7e8 !default; + +:host { + .tb-led-indicator { + width: 100%; + height: 100%; + background: $background-color; + + .title-container { + .led-title { + font-weight: 500; + color: #757575; + white-space: nowrap; + } + } + + .error-container { + position: absolute; + top: 1%; + right: 0; + left: 0; + z-index: 4; + height: $error-height; + + .led-error { + color: #ff3315; + white-space: nowrap; + } + } + + #text-measure { + position: absolute; + width: auto; + height: auto; + white-space: nowrap; + visibility: hidden; + } + + #led-container { + padding: 10px; + + .led { + position: relative; + cursor: pointer; + background-image: radial-gradient(circle closest-corner at 50% 50%, transparent, rgba(0, 0, 0, .25)); + border-radius: 50%; + transition: background-color .5s, box-shadow .5s; + + &.disabled { + background-image: radial-gradient(circle closest-corner at 50% 50%, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts new file mode 100644 index 0000000000..c307018976 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts @@ -0,0 +1,342 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { isDefined } from '@core/utils'; +import * as tinycolor_ from 'tinycolor2'; +import { UtilsService } from '@core/services/utils.service'; +import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { DatasourceType, widgetType } from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import Timeout = NodeJS.Timeout; + +const tinycolor = tinycolor_; + +const checkStatusPollingInterval = 10000; + +type RetrieveValueMethod = 'attribute' | 'timeseries'; + +interface LedIndicatorSettings { + initialValue: boolean; + title: string; + ledColor: string; + performCheckStatus: boolean; + checkStatusMethod: string; + retrieveValueMethod: RetrieveValueMethod; + valueAttribute: string; + parseValueFunction: string; + requestTimeout: number; +} + +@Component({ + selector: 'tb-led-indicator', + templateUrl: './led-indicator.component.html', + styleUrls: ['./led-indicator.component.scss'] +}) +export class LedIndicatorComponent extends PageComponent implements OnInit, OnDestroy { + + @ViewChild('led', {static: true}) ledRef: ElementRef; + @ViewChild('ledContainer', {static: true}) ledContainerRef: ElementRef; + @ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef; + @ViewChild('ledTitleContainer', {static: true}) ledTitleContainerRef: ElementRef; + @ViewChild('ledTitle', {static: true}) ledTitleRef: ElementRef; + @ViewChild('ledErrorContainer', {static: true}) ledErrorContainerRef: ElementRef; + @ViewChild('ledError', {static: true}) ledErrorRef: ElementRef; + + @Input() + ctx: WidgetContext; + + showTitle = false; + value = false; + error = ''; + title = ''; + + private valueAttribute: string; + private ledColor: string; + private ledMiddleColor: string; + private disabledColor: string; + private disabledMiddleColor: string; + + private isSimulated: boolean; + private requestTimeout: number; + private retrieveValueMethod: RetrieveValueMethod; + private parseValueFunction: (data: any) => boolean; + private performCheckStatus: boolean; + private checkStatusMethod: string; + + private destroyed = false; + private checkStatusTimeoutHandle: Timeout; + private subscription: IWidgetSubscription; + + private led: JQuery; + private ledContainer: JQuery; + private textMeasure: JQuery; + private ledTitleContainer: JQuery; + private ledTitle: JQuery; + private ledErrorContainer: JQuery; + private ledError: JQuery; + + private ledResizeListener: any; + + private subscriptionOptions: WidgetSubscriptionOptions = { + callbacks: { + onDataUpdated: this.onDataUpdated.bind(this), + onDataUpdateError: this.onDataUpdateError.bind(this), + dataLoading: () => {} + } + }; + + + constructor(private utils: UtilsService, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.led = $(this.ledRef.nativeElement); + this.ledContainer = $(this.ledContainerRef.nativeElement); + this.textMeasure = $(this.textMeasureRef.nativeElement); + this.ledTitleContainer = $(this.ledTitleContainerRef.nativeElement); + this.ledTitle = $(this.ledTitleRef.nativeElement); + this.ledErrorContainer = $(this.ledErrorContainerRef.nativeElement); + this.ledError = $(this.ledErrorRef.nativeElement); + + this.ledResizeListener = this.resize.bind(this); + // @ts-ignore + addResizeListener(this.ledContainerRef.nativeElement, this.ledResizeListener); + this.init(); + } + + ngOnDestroy(): void { + this.destroyed = true; + if (this.checkStatusTimeoutHandle) { + clearTimeout(this.checkStatusTimeoutHandle); + } + if (this.subscription) { + this.ctx.subscriptionApi.removeSubscription(this.subscription.id); + } + if (this.ledResizeListener) { + // @ts-ignore + removeResizeListener(this.ledContainerRef.nativeElement, this.ledResizeListener); + } + } + + private init() { + const settings: LedIndicatorSettings = this.ctx.settings; + this.title = isDefined(settings.title) ? settings.title : ''; + this.showTitle = !!(this.title && this.title.length); + + const origColor = isDefined(settings.ledColor) ? settings.ledColor : 'green'; + this.valueAttribute = isDefined(settings.valueAttribute) ? settings.valueAttribute : 'value'; + + this.ledColor = tinycolor(origColor).brighten(30).toHexString(); + this.ledMiddleColor = tinycolor(origColor).toHexString(); + this.disabledColor = tinycolor(origColor).darken(40).toHexString(); + this.disabledMiddleColor = tinycolor(origColor).darken(60).toHexString(); + + const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false; + this.setValue(initialValue, true); + + const subscription = this.ctx.defaultSubscription; + const rpcEnabled = subscription.rpcEnabled; + + this.isSimulated = this.utils.widgetEditMode; + + this.requestTimeout = 500; + if (settings.requestTimeout) { + this.requestTimeout = settings.requestTimeout; + } + this.retrieveValueMethod = 'attribute'; + if (settings.retrieveValueMethod && settings.retrieveValueMethod.length) { + this.retrieveValueMethod = settings.retrieveValueMethod; + } + this.parseValueFunction = (data) => !!data; + if (settings.parseValueFunction && settings.parseValueFunction.length) { + try { + this.parseValueFunction = new Function('data', settings.parseValueFunction) as (data: any) => boolean; + } catch (e) { + this.parseValueFunction = (data) => !!data; + } + } + this.performCheckStatus = settings.performCheckStatus !== false; + if (this.performCheckStatus) { + this.checkStatusMethod = 'checkStatus'; + if (settings.checkStatusMethod && settings.checkStatusMethod.length) { + this.checkStatusMethod = settings.checkStatusMethod; + } + } + if (!rpcEnabled) { + this.onError('Target device is not set!'); + } else { + if (!this.isSimulated) { + if (this.performCheckStatus) { + this.rpcCheckStatus(); + } else { + this.subscribeForValue(); + } + } + } + } + + private resize() { + const width = this.ledContainer.width(); + const height = this.ledContainer.height(); + const size = Math.min(width, height); + + this.led.css({width: size, height: size}); + + if (this.showTitle) { + this.setFontSize(this.ledTitle, this.title, this.ledTitleContainer.height() * 2 / 3, this.ledTitleContainer.width()); + } + this.setFontSize(this.ledError, this.error, this.ledErrorContainer.height(), this.ledErrorContainer.width()); + } + + private setFontSize(element: JQuery, text: string, fontSize: number, maxWidth: number) { + let textWidth = this.measureTextWidth(text, fontSize); + while (textWidth > maxWidth) { + fontSize--; + textWidth = this.measureTextWidth(text, fontSize); + } + element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + } + + private measureTextWidth(text: string, fontSize: number): number { + this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + this.textMeasure.text(text); + return this.textMeasure.width(); + } + + private onError(error: string) { + this.error = error; + this.setFontSize(this.ledError, this.error, this.ledErrorContainer.height(), this.ledErrorContainer.width()); + this.ctx.detectChanges(); + } + + private setValue(value: boolean, forceUpdate?: boolean) { + if (this.value !== value || forceUpdate) { + this.value = value; + this.updateColor(); + } + } + + private updateColor() { + const color = this.value ? this.ledColor : this.disabledColor; + const middleColor = this.value ? this.ledMiddleColor : this.disabledMiddleColor; + const boxShadow = `#000 0 -1px 6px 1px, inset ${middleColor} 0 -1px 8px, ${color} 0 3px 11px`; + this.led.css({backgroundColor: color}); + this.led.css({boxShadow}); + if (this.value) { + this.led.removeClass( 'disabled' ); + } else { + this.led.addClass( 'disabled' ); + } + } + + private rpcCheckStatus() { + if (this.destroyed) { + return; + } + this.error = ''; + this.ctx.controlApi.sendTwoWayCommand(this.checkStatusMethod, null, this.requestTimeout).subscribe( + (responseBody) => { + const status = !!responseBody; + if (status) { + if (this.checkStatusTimeoutHandle) { + clearTimeout(this.checkStatusTimeoutHandle); + this.checkStatusTimeoutHandle = null; + } + this.subscribeForValue(); + } else { + const errorText = 'Unknown device status!'; + this.onError(errorText); + if (this.checkStatusTimeoutHandle) { + clearTimeout(this.checkStatusTimeoutHandle); + } + this.checkStatusTimeoutHandle = setTimeout(this.rpcCheckStatus.bind(this), checkStatusPollingInterval); + } + }, + () => { + const errorText = this.ctx.defaultSubscription.rpcErrorText; + this.onError(errorText); + if (this.checkStatusTimeoutHandle) { + clearTimeout(this.checkStatusTimeoutHandle); + } + this.checkStatusTimeoutHandle = setTimeout(this.rpcCheckStatus.bind(this), checkStatusPollingInterval); + } + ); + } + + private subscribeForValue() { + const subscriptionsInfo: SubscriptionInfo[] = [{ + type: DatasourceType.entity, + entityType: EntityType.DEVICE, + entityId: this.ctx.defaultSubscription.targetDeviceId + }]; + + if (this.retrieveValueMethod === 'attribute') { + subscriptionsInfo[0].attributes = [ + {name: this.valueAttribute} + ]; + } else { + subscriptionsInfo[0].timeseries = [ + {name: this.valueAttribute} + ]; + } + + this.ctx.subscriptionApi.createSubscriptionFromInfo ( + widgetType.latest, subscriptionsInfo, this.subscriptionOptions, false, true).subscribe( + (subscription) => { + this.subscription = subscription; + } + ); + } + + private onDataUpdated (subscription: IWidgetSubscription, detectChanges: boolean) { + let value = false; + const data = subscription.data; + if (data.length) { + const keyData = data[0]; + if (keyData && keyData.data && keyData.data[0]) { + const attrValue = keyData.data[0][1]; + if (attrValue) { + let parsed = null; + try { + parsed = this.parseValueFunction(JSON.parse(attrValue)); + } catch (e){/**/} + value = !!parsed; + } + } + } + this.setValue(value); + if (detectChanges) { + this.ctx.detectChanges(); + } + } + + private onDataUpdateError(subscription: IWidgetSubscription, e: any) { + const exceptionData = this.utils.parseException(e); + let errorText = exceptionData.name; + if (exceptionData.message) { + errorText += ': ' + exceptionData.message; + } + this.onError(errorText); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html new file mode 100644 index 0000000000..9ffe802151 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html @@ -0,0 +1,38 @@ + +
+
+ {{title}} +
+
+
+ +
+ +
+
+
+
+ {{ error }} +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.scss new file mode 100644 index 0000000000..99cbe764eb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.scss @@ -0,0 +1,198 @@ +/** + * Copyright © 2016-2019 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 "~compass-sass-mixins/lib/compass"; + +$error-height: 14px !default; + +$background-color: #e6e7e8 !default; + +:host { + .tb-round-switch { + width: 100%; + height: 100%; + background: $background-color; + + .title-container { + .switch-title { + font-weight: 500; + color: #757575; + white-space: nowrap; + } + } + + .error-container { + position: absolute; + top: 1%; + right: 0; + left: 0; + z-index: 4; + height: $error-height; + + .switch-error { + color: #ff3315; + white-space: nowrap; + } + } + + #text-measure { + position: absolute; + width: auto; + height: auto; + white-space: nowrap; + visibility: hidden; + } + + #switch-container { + padding: 10px; + + .switch { + position: relative; + + box-sizing: border-box; + width: 260px; + min-width: 260px; + height: 260px; + min-height: 260px; + padding: 25px; + font-family: sans-serif; + font-size: 48px; + + color: #424242; + cursor: pointer; + background: #ddd; + background: linear-gradient(180deg, #bbb, #ddd); + border-radius: 130px; + + box-shadow: 0 0 0 8px rgba(0, 0, 0, .1), + 0 0 3px 1px rgba(0, 0, 0, .1), + inset 0 8px 3px -8px rgba(255, 255, 255, .4); + + input { + display: none; + } + + .on, + .off { + position: absolute; + width: 100%; + text-align: center; + + text-shadow: 1px 1px 4px #4a4a4a; + } + + .on { + top: 10px; + font-family: sans-serif; + color: #444; + + transition: all .1s; + } + + .off { + bottom: 5px; + + transition: all .1s; + + transform: scaleY(.85); + } + + .but { + position: relative; + display: block; + width: 200px; + height: 178px; + font-size: 48px; + cursor: pointer; + background-color: #d8d8d8; + border-bottom-width: 0; + border-radius: 400px 400px 400px 400px / 400px 400px 300px 300px; + + box-shadow: inset 8px 6px 5px -7px #a2a2a2, + inset -8px 6px 5px -7px #a2a2a2, + inset 0 -3px 2px -2px rgba(200, 200, 200, .5), + 0 3px 3px -2px #fff, + inset 0 -230px 60px -200px rgba(255, 255, 255, .2), + inset 0 220px 40px -200px rgba(0, 0, 0, .3); + + transition: all .2s; + } + + .back { + + box-sizing: border-box; + width: 210px; + height: 210px; + padding: 4px 4px; + cursor: pointer; + background-color: #888787; + background-image: linear-gradient(-90deg, transparent 30%, transparent 70%), linear-gradient(0deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); + border-radius: 105px; + + box-shadow: 30px 30px 30px -20px rgba(58, 58, 58, .3), + -30px 30px 30px -20px rgba(58, 58, 58, .3), + 0 30px 30px 0 rgba(16, 16, 16, .3), + inset 0 -1px 0 0 #484848; + + transition: all .2s; + } + + + input:checked + .back .on, + input:checked + .back .off { + text-shadow: 1px 1px 4px #4a4a4a; + } + + input:checked + .back .on { + top: 10px; + color: #4c4c4c; + + transform: scaleY(.85); + } + + input:checked + .back .off { + bottom: 5px; + color: #444; + + transform: scaleY(1); + } + + input:checked + .back .but { + margin-top: 20px; + background: #dcdcdc radial-gradient(circle closest-corner at 50% 15%, rgba(0, 0, 0, .3), transparent); + border-radius: 400px 400px 400px 400px / 300px 300px 400px 400px; + + box-shadow: inset 8px -4px 5px -7px #a9a9a9, + inset -8px -4px 5px -7px #808080, + 0 -3px 8px -4px rgba(50, 50, 50, .4), + inset 0 3px 4px -2px #9c9c9c, + inset 0 280px 40px -200px rgba(0, 0, 0, .2), + inset 0 -200px 40px -200px rgba(180, 180, 180, .2); + } + + input:checked + .back { + padding: 2px 4px; + + background-image: linear-gradient(0deg, #868686 30%, transparent 70%), linear-gradient(90deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); + + box-shadow: 30px 30px 30px -20px rgba(49, 49, 49, .1), + -30px 30px 30px -20px rgba(111, 111, 111, .1), + 0 30px 30px 0 rgba(0, 0, 0, .2), + inset 0 1px 2px 0 rgba(167, 167, 167, .6); + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts new file mode 100644 index 0000000000..3be714fd2b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts @@ -0,0 +1,338 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { UtilsService } from '@core/services/utils.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { isDefined } from '@core/utils'; +import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { DatasourceType, widgetType } from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; + +type RetrieveValueMethod = 'rpc' | 'attribute' | 'timeseries'; + +interface RoundSwitchSettings { + initialValue: boolean; + title: string; + retrieveValueMethod: RetrieveValueMethod; + valueKey: string; + getValueMethod: string; + setValueMethod: string; + parseValueFunction: string; + convertValueFunction: string; + requestTimeout: number; +} + +@Component({ + selector: 'tb-round-switch', + templateUrl: './round-switch.component.html', + styleUrls: ['./round-switch.component.scss'] +}) +export class RoundSwitchComponent extends PageComponent implements OnInit, OnDestroy { + + @ViewChild('switch', {static: true}) switchElementRef: ElementRef; + @ViewChild('switchContainer', {static: true}) switchContainerRef: ElementRef; + @ViewChild('onoff', {static: true}) onoffRef: ElementRef; + @ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef; + @ViewChild('switchTitleContainer', {static: true}) switchTitleContainerRef: ElementRef; + @ViewChild('switchTitle', {static: true}) switchTitleRef: ElementRef; + @ViewChild('switchErrorContainer', {static: true}) switchErrorContainerRef: ElementRef; + @ViewChild('switchError', {static: true}) switchErrorRef: ElementRef; + + @Input() + ctx: WidgetContext; + + showTitle = false; + value = false; + error = ''; + title = ''; + checkboxId = 'onoff-' + this.utils.guid(); + + private isSimulated: boolean; + private requestTimeout: number; + private retrieveValueMethod: RetrieveValueMethod; + private valueKey: string; + private parseValueFunction: (data: any) => boolean; + private convertValueFunction: (value: any) => any; + private getValueMethod: string; + private setValueMethod: string; + + private valueSubscription: IWidgetSubscription; + + private executingUpdateValue: boolean; + private scheduledValue: boolean; + private rpcValue: boolean; + + private switchElement: JQuery; + private switchContainer: JQuery; + private onoff: JQuery; + private textMeasure: JQuery; + private switchTitleContainer: JQuery; + private switchTitle: JQuery; + private switchErrorContainer: JQuery; + private switchError: JQuery; + + private switchResizeListener: any; + + constructor(private utils: UtilsService, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.switchElement = $(this.switchElementRef.nativeElement); + this.switchContainer = $(this.switchContainerRef.nativeElement); + this.onoff = $(this.onoffRef.nativeElement); + this.textMeasure = $(this.textMeasureRef.nativeElement); + this.switchTitleContainer = $(this.switchTitleContainerRef.nativeElement); + this.switchTitle = $(this.switchTitleRef.nativeElement); + this.switchErrorContainer = $(this.switchErrorContainerRef.nativeElement); + this.switchError = $(this.switchErrorRef.nativeElement); + + this.onoff.on('change', () => { + this.value = this.onoff.prop('checked') === false; + this.onValue(); + }); + + this.switchResizeListener = this.resize.bind(this); + // @ts-ignore + addResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener); + this.init(); + // this.ctx.resize = this.resize.bind(this); + } + + ngOnDestroy(): void { + if (this.valueSubscription) { + this.ctx.subscriptionApi.removeSubscription(this.valueSubscription.id); + } + if (this.switchResizeListener) { + // @ts-ignore + removeResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener); + } + } + + private init() { + const settings: RoundSwitchSettings = this.ctx.settings; + this.title = isDefined(settings.title) ? settings.title : ''; + this.showTitle = !!(this.title && this.title.length); + const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false; + this.setValue(initialValue); + + const subscription = this.ctx.defaultSubscription; + const rpcEnabled = subscription.rpcEnabled; + + this.isSimulated = this.utils.widgetEditMode; + + this.requestTimeout = 500; + if (settings.requestTimeout) { + this.requestTimeout = settings.requestTimeout; + } + this.retrieveValueMethod = 'rpc'; + if (settings.retrieveValueMethod && settings.retrieveValueMethod.length) { + this.retrieveValueMethod = settings.retrieveValueMethod; + } + this.valueKey = 'value'; + if (settings.valueKey && settings.valueKey.length) { + this.valueKey = settings.valueKey; + } + this.parseValueFunction = (data) => !!data; + if (settings.parseValueFunction && settings.parseValueFunction.length) { + try { + this.parseValueFunction = new Function('data', settings.parseValueFunction) as (data: any) => boolean; + } catch (e) { + this.parseValueFunction = (data) => !!data; + } + } + this.convertValueFunction = (value) => value; + if (settings.convertValueFunction && settings.convertValueFunction.length) { + try { + this.convertValueFunction = new Function('value', settings.convertValueFunction) as (value: any) => any; + } catch (e) { + this.convertValueFunction = (value) => value; + } + } + this.getValueMethod = 'getValue'; + if (settings.getValueMethod && settings.getValueMethod.length) { + this.getValueMethod = settings.getValueMethod; + } + this.setValueMethod = 'setValue'; + if (settings.setValueMethod && settings.setValueMethod.length) { + this.setValueMethod = settings.setValueMethod; + } + if (!rpcEnabled) { + this.onError('Target device is not set!'); + } else { + if (!this.isSimulated) { + if (this.retrieveValueMethod === 'rpc') { + this.rpcRequestValue(); + } else if (this.retrieveValueMethod === 'attribute' || this.retrieveValueMethod === 'timeseries') { + this.subscribeForValue(); + } + } + } + + } + + private resize() { + const width = this.switchContainer.width(); + const height = this.switchContainer.height(); + const size = Math.min(width, height); + const scale = size/260; + this.switchElement.css({ + '-webkit-transform': `scale(${scale})`, + '-moz-transform': `scale(${scale})`, + '-ms-transform': `scale(${scale})`, + '-o-transform': `scale(${scale})`, + transform: `scale(${scale})` + }); + if (this.showTitle) { + this.setFontSize(this.switchTitle, this.title, this.switchTitleContainer.height() * 2 / 3, this.switchTitleContainer.width()); + } + this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); + } + + private setFontSize(element: JQuery, text: string, fontSize: number, maxWidth: number) { + let textWidth = this.measureTextWidth(text, fontSize); + while (textWidth > maxWidth) { + fontSize--; + textWidth = this.measureTextWidth(text, fontSize); + } + element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + } + + private measureTextWidth(text: string, fontSize: number): number { + this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + this.textMeasure.text(text); + return this.textMeasure.width(); + } + + private onError(error: string) { + this.error = error; + this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); + this.ctx.detectChanges(); + } + + private setValue(value: boolean) { + this.value = value ? true : false; + this.onoff.prop('checked', !this.value); + } + + private onValue() { + this.rpcUpdateValue(this.value); + } + + private rpcRequestValue() { + this.error = ''; + this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe( + (responseBody) => { + this.setValue(this.parseValueFunction(responseBody)); + }, + () => { + const errorText = this.ctx.defaultSubscription.rpcErrorText; + this.onError(errorText); + } + ); + } + + private rpcUpdateValue(value) { + if (this.executingUpdateValue) { + this.scheduledValue = value; + return; + } else { + this.scheduledValue = null; + this.rpcValue = value; + this.executingUpdateValue = true; + } + this.error = ''; + this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, this.convertValueFunction(value), this.requestTimeout).subscribe( + () => { + this.executingUpdateValue = false; + if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) { + this.rpcUpdateValue(this.scheduledValue); + } + }, + () => { + this.executingUpdateValue = false; + const errorText = this.ctx.defaultSubscription.rpcErrorText; + this.onError(errorText); + } + ); + } + + private subscribeForValue() { + const valueSubscriptionInfo: SubscriptionInfo[] = [{ + type: DatasourceType.entity, + entityType: EntityType.DEVICE, + entityId: this.ctx.defaultSubscription.targetDeviceId + }]; + if (this.retrieveValueMethod === 'attribute') { + valueSubscriptionInfo[0].attributes = [ + {name: this.valueKey} + ]; + } else { + valueSubscriptionInfo[0].timeseries = [ + {name: this.valueKey} + ]; + } + const subscriptionOptions: WidgetSubscriptionOptions = { + callbacks: { + onDataUpdated: this.onDataUpdated.bind(this), + onDataUpdateError: this.onDataUpdateError.bind(this) + } + }; + this.ctx.subscriptionApi.createSubscriptionFromInfo ( + widgetType.latest, valueSubscriptionInfo, subscriptionOptions, false, true).subscribe( + (subscription) => { + this.valueSubscription = subscription; + } + ); + } + + private onDataUpdated(subscription: IWidgetSubscription, detectChanges: boolean) { + let value = false; + const data = subscription.data; + if (data.length) { + const keyData = data[0]; + if (keyData && keyData.data && keyData.data[0]) { + const attrValue = keyData.data[0][1]; + if (attrValue) { + let parsed = null; + try { + parsed = this.parseValueFunction(JSON.parse(attrValue)); + } catch (e){/**/} + value = !!parsed; + } + } + } + this.setValue(value); + if (detectChanges) { + this.ctx.detectChanges(); + } + } + + private onDataUpdateError(subscription: IWidgetSubscription, e: any) { + const exceptionData = this.utils.parseException(e); + let errorText = exceptionData.name; + if (exceptionData.message) { + errorText += ': ' + exceptionData.message; + } + this.onError(errorText); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widgets.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widgets.module.ts new file mode 100644 index 0000000000..3d50ef8b81 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widgets.module.ts @@ -0,0 +1,44 @@ +/// +/// Copyright © 2016-2019 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@app/shared/shared.module'; +import { LedIndicatorComponent } from '@home/components/widget/lib/rpc/led-indicator.component'; +import { RoundSwitchComponent } from '@home/components/widget/lib/rpc/round-switch.component'; +import { SwitchComponent } from '@home/components/widget/lib/rpc/switch.component'; +import { KnobComponent } from '@home/components/widget/lib/rpc/knob.component'; + +@NgModule({ + declarations: + [ + LedIndicatorComponent, + RoundSwitchComponent, + SwitchComponent, + KnobComponent + ], + imports: [ + CommonModule, + SharedModule + ], + exports: [ + LedIndicatorComponent, + RoundSwitchComponent, + SwitchComponent, + KnobComponent + ] +}) +export class RpcWidgetsModule { } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/knob.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/knob.svg new file mode 100644 index 0000000000..81fe9949b7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/knob.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar-checked.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar-checked.svg new file mode 100644 index 0000000000..57fa2b423e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar-checked.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar.svg new file mode 100644 index 0000000000..0b952933a4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-checked.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-checked.svg new file mode 100644 index 0000000000..6d59b94736 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-checked.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb.svg new file mode 100644 index 0000000000..03e9ea0f77 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html new file mode 100644 index 0000000000..29747503d9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html @@ -0,0 +1,39 @@ + +
+
+ {{ error }} +
+
+ {{title}} +
+
+
+ + +
+
+ OFF + + ON + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss new file mode 100644 index 0000000000..72edb278e8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss @@ -0,0 +1,147 @@ +/** + * Copyright © 2016-2019 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. + */ + + +$thumb-img: url("./svg/thumb.svg") !default; +$thumb-checked-img: url("./svg/thumb-checked.svg") !default; +$thumb-bar-img: url("./svg/thumb-bar.svg") !default; +$thumb-bar-checked-img: url("./svg/thumb-bar-checked.svg") !default; + +$background-color: #e6e7e8 !default; + +$error-height: 14px !default; + +:host { + + .tb-switch { + width: 100%; + height: 100%; + background: $background-color; + + .error-container { + position: absolute; + top: 1%; + right: 0; + left: 0; + z-index: 4; + height: $error-height; + + .switch-error { + color: #ff3315; + white-space: nowrap; + } + } + + .onoff-container { + height: 100%; + font-weight: 500; + color: #757575; + white-space: nowrap; + + .off-label { + color: #b7b5b5; + } + + .on-label { + color: #ff7e57; + text-shadow: #ff6e4a 1px 1px 10px, #ffd1c3 1px 1px 10px; + } + } + + .title-container { + .switch-title { + font-weight: 500; + color: #757575; + white-space: nowrap; + } + } + + #switch-container { + padding-right: 10px; + padding-left: 10px; + } + + .switch { + position: relative; + } + + #text-measure { + position: absolute; + width: auto; + height: auto; + white-space: nowrap; + visibility: hidden; + } + } +} + +:host ::ng-deep { + .tb-switch { + .switch { + mat-slide-toggle { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: 0; + + .mat-slide-toggle-bar { + top: 0; + left: 0; + width: 100%; + height: 100%; + background: $thumb-bar-img no-repeat; + background-size: contain; + border-radius: 0; + } + + .mat-slide-toggle-thumb-container { + top: 5%; + left: .25%; + width: 50%; + height: 90%; + } + + .mat-slide-toggle-thumb { + top: 0; + left: 0; + width: 100%; + height: 100%; + background: $thumb-img no-repeat; + background-size: contain; + border-radius: 0; + box-shadow: none; + } + + &.mat-checked { + .mat-slide-toggle-thumb-container { + transform: translate3d(100%,0,0); + } + .mat-slide-toggle-bar { + background: $thumb-bar-checked-img no-repeat; + background-size: contain; + } + + .mat-slide-toggle-thumb { + background: $thumb-checked-img no-repeat; + background-size: contain; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts new file mode 100644 index 0000000000..b91e32bf4d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts @@ -0,0 +1,354 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { UtilsService } from '@core/services/utils.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { isDefined } from '@core/utils'; +import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { DatasourceType, widgetType } from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { MatSlideToggle } from '@angular/material/slide-toggle'; + +const switchAspectRation = 2.7893; + +type RetrieveValueMethod = 'rpc' | 'attribute' | 'timeseries'; + +interface SwitchSettings { + initialValue: boolean; + title: string; + showOnOffLabels: boolean; + retrieveValueMethod: RetrieveValueMethod; + valueKey: string; + getValueMethod: string; + setValueMethod: string; + parseValueFunction: string; + convertValueFunction: string; + requestTimeout: number; +} + +@Component({ + selector: 'tb-switch', + templateUrl: './switch.component.html', + styleUrls: ['./switch.component.scss'] +}) +export class SwitchComponent extends PageComponent implements OnInit, OnDestroy { + + @ViewChild('switch', {static: true}) switchElementRef: ElementRef; + @ViewChild('switchContainer', {static: true}) switchContainerRef: ElementRef; + @ViewChild('matSlideToggle', {static: true}) matSlideToggleRef: MatSlideToggle; + @ViewChild('onoffContainer', {static: true}) onoffContainerRef: ElementRef; + @ViewChild('onLabel', {static: true}) onLabelRef: ElementRef; + @ViewChild('offLabel', {static: true}) offLabelRef: ElementRef; + @ViewChild('switchTitleContainer', {static: true}) switchTitleContainerRef: ElementRef; + @ViewChild('switchTitle', {static: true}) switchTitleRef: ElementRef; + @ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef; + @ViewChild('switchErrorContainer', {static: true}) switchErrorContainerRef: ElementRef; + @ViewChild('switchError', {static: true}) switchErrorRef: ElementRef; + + @Input() + ctx: WidgetContext; + + showTitle = false; + value = false; + error = ''; + title = ''; + showOnOffLabels = false; + + private isSimulated: boolean; + private requestTimeout: number; + private retrieveValueMethod: RetrieveValueMethod; + private valueKey: string; + private parseValueFunction: (data: any) => boolean; + private convertValueFunction: (value: any) => any; + private getValueMethod: string; + private setValueMethod: string; + + private valueSubscription: IWidgetSubscription; + + private executingUpdateValue: boolean; + private scheduledValue: boolean; + private rpcValue: boolean; + + private switchElement: JQuery; + private switchContainer: JQuery; + private matSlideToggle: JQuery; + private onoffContainer: JQuery; + private onLabel: JQuery; + private offLabel: JQuery; + private switchTitleContainer: JQuery; + private switchTitle: JQuery; + private textMeasure: JQuery; + private switchErrorContainer: JQuery; + private switchError: JQuery; + + private switchResizeListener: any; + + constructor(private utils: UtilsService, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.switchElement = $(this.switchElementRef.nativeElement); + this.switchContainer = $(this.switchContainerRef.nativeElement); + this.matSlideToggle = $(this.matSlideToggleRef._elementRef.nativeElement); + this.onoffContainer = $(this.onoffContainerRef.nativeElement); + this.onLabel = $(this.onLabelRef.nativeElement); + this.offLabel = $(this.offLabelRef.nativeElement); + this.switchTitleContainer = $(this.switchTitleContainerRef.nativeElement); + this.switchTitle = $(this.switchTitleRef.nativeElement); + this.textMeasure = $(this.textMeasureRef.nativeElement); + this.switchErrorContainer = $(this.switchErrorContainerRef.nativeElement); + this.switchError = $(this.switchErrorRef.nativeElement); + + this.switchResizeListener = this.resize.bind(this); + // @ts-ignore + addResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener); + this.init(); + } + + ngOnDestroy(): void { + if (this.valueSubscription) { + this.ctx.subscriptionApi.removeSubscription(this.valueSubscription.id); + } + if (this.switchResizeListener) { + // @ts-ignore + removeResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener); + } + } + + private init() { + const settings: SwitchSettings = this.ctx.settings; + this.title = isDefined(settings.title) ? settings.title : ''; + this.showTitle = !!(this.title && this.title.length); + this.showOnOffLabels = isDefined(settings.showOnOffLabels) ? settings.showOnOffLabels : true; + const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false; + this.setValue(initialValue); + + const subscription = this.ctx.defaultSubscription; + const rpcEnabled = subscription.rpcEnabled; + + this.isSimulated = this.utils.widgetEditMode; + + this.requestTimeout = 500; + if (settings.requestTimeout) { + this.requestTimeout = settings.requestTimeout; + } + this.retrieveValueMethod = 'rpc'; + if (settings.retrieveValueMethod && settings.retrieveValueMethod.length) { + this.retrieveValueMethod = settings.retrieveValueMethod; + } + this.valueKey = 'value'; + if (settings.valueKey && settings.valueKey.length) { + this.valueKey = settings.valueKey; + } + this.parseValueFunction = (data) => !!data; + if (settings.parseValueFunction && settings.parseValueFunction.length) { + try { + this.parseValueFunction = new Function('data', settings.parseValueFunction) as (data: any) => boolean; + } catch (e) { + this.parseValueFunction = (data) => !!data; + } + } + this.convertValueFunction = (value) => value; + if (settings.convertValueFunction && settings.convertValueFunction.length) { + try { + this.convertValueFunction = new Function('value', settings.convertValueFunction) as (value: any) => any; + } catch (e) { + this.convertValueFunction = (value) => value; + } + } + this.getValueMethod = 'getValue'; + if (settings.getValueMethod && settings.getValueMethod.length) { + this.getValueMethod = settings.getValueMethod; + } + this.setValueMethod = 'setValue'; + if (settings.setValueMethod && settings.setValueMethod.length) { + this.setValueMethod = settings.setValueMethod; + } + if (!rpcEnabled) { + this.onError('Target device is not set!'); + } else { + if (!this.isSimulated) { + if (this.retrieveValueMethod === 'rpc') { + this.rpcRequestValue(); + } else if (this.retrieveValueMethod === 'attribute' || this.retrieveValueMethod === 'timeseries') { + this.subscribeForValue(); + } + } + } + + } + + private resize() { + let width = this.switchContainer.width(); + let height = this.switchContainer.height(); + if (this.showOnOffLabels) { + height = height*2/3; + } + const ratio = width/height; + if (ratio > switchAspectRation) { + width = height*switchAspectRation; + } else { + height = width/switchAspectRation; + } + this.switchElement.css({width, height}); + this.matSlideToggle.css({width, height, minWidth: width}); + + if (this.showTitle) { + this.setFontSize(this.switchTitle, this.title, this.switchTitleContainer.height() * 2 / 3, this.switchTitleContainer.width()); + } + + if (this.showOnOffLabels) { + this.onoffContainer.css({width, height: this.switchContainer.height() / 3}); + this.setFontSize(this.onLabel, 'OFF', this.onoffContainer.height(), this.onoffContainer.width() / 2); + this.setFontSize(this.offLabel, 'OFF', this.onoffContainer.height(), this.onoffContainer.width() / 2); + } + + this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); + } + + private setFontSize(element: JQuery, text: string, fontSize: number, maxWidth: number) { + let textWidth = this.measureTextWidth(text, fontSize); + while (textWidth > maxWidth) { + fontSize--; + textWidth = this.measureTextWidth(text, fontSize); + } + element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + } + + private measureTextWidth(text: string, fontSize: number): number { + this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + this.textMeasure.text(text); + return this.textMeasure.width(); + } + + private onError(error: string) { + this.error = error; + this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); + this.ctx.detectChanges(); + } + + private setValue(value: boolean) { + this.value = value ? true : false; + } + + public onValue() { + this.rpcUpdateValue(this.value); + } + + private rpcRequestValue() { + this.error = ''; + this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe( + (responseBody) => { + this.setValue(this.parseValueFunction(responseBody)); + }, + () => { + const errorText = this.ctx.defaultSubscription.rpcErrorText; + this.onError(errorText); + } + ); + } + + private rpcUpdateValue(value) { + if (this.executingUpdateValue) { + this.scheduledValue = value; + return; + } else { + this.scheduledValue = null; + this.rpcValue = value; + this.executingUpdateValue = true; + } + this.error = ''; + this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, this.convertValueFunction(value), this.requestTimeout).subscribe( + () => { + this.executingUpdateValue = false; + if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) { + this.rpcUpdateValue(this.scheduledValue); + } + }, + () => { + this.executingUpdateValue = false; + const errorText = this.ctx.defaultSubscription.rpcErrorText; + this.onError(errorText); + } + ); + } + + private subscribeForValue() { + const valueSubscriptionInfo: SubscriptionInfo[] = [{ + type: DatasourceType.entity, + entityType: EntityType.DEVICE, + entityId: this.ctx.defaultSubscription.targetDeviceId + }]; + if (this.retrieveValueMethod === 'attribute') { + valueSubscriptionInfo[0].attributes = [ + {name: this.valueKey} + ]; + } else { + valueSubscriptionInfo[0].timeseries = [ + {name: this.valueKey} + ]; + } + const subscriptionOptions: WidgetSubscriptionOptions = { + callbacks: { + onDataUpdated: this.onDataUpdated.bind(this), + onDataUpdateError: this.onDataUpdateError.bind(this) + } + }; + this.ctx.subscriptionApi.createSubscriptionFromInfo ( + widgetType.latest, valueSubscriptionInfo, subscriptionOptions, false, true).subscribe( + (subscription) => { + this.valueSubscription = subscription; + } + ); + } + + private onDataUpdated(subscription: IWidgetSubscription, detectChanges: boolean) { + let value = false; + const data = subscription.data; + if (data.length) { + const keyData = data[0]; + if (keyData && keyData.data && keyData.data[0]) { + const attrValue = keyData.data[0][1]; + if (attrValue) { + let parsed = null; + try { + parsed = this.parseValueFunction(JSON.parse(attrValue)); + } catch (e){/**/} + value = !!parsed; + } + } + } + this.setValue(value); + if (detectChanges) { + this.ctx.detectChanges(); + } + } + + private onDataUpdateError(subscription: IWidgetSubscription, e: any) { + const exceptionData = this.utils.parseException(e); + let errorText = exceptionData.name; + if (exceptionData.message) { + errorText += ': ' + exceptionData.message; + } + this.onError(errorText); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 3817481d85..248c2b48b5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -25,6 +25,7 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/timeseries-table-widget.component'; import { EntitiesHierarchyWidgetComponent } from '@home/components/widget/lib/entities-hierarchy-widget.component'; import { CustomDialogService } from '@home/components/widget/dialog/custom-dialog.service'; +import { RpcWidgetsModule } from '@home/components/widget/lib/rpc/rpc-widgets.module'; @NgModule({ declarations: @@ -39,13 +40,15 @@ import { CustomDialogService } from '@home/components/widget/dialog/custom-dialo imports: [ CommonModule, SharedModule, + RpcWidgetsModule, SharedHomeComponentsModule ], exports: [ EntitiesTableWidgetComponent, AlarmsTableWidgetComponent, TimeseriesTableWidgetComponent, - EntitiesHierarchyWidgetComponent + EntitiesHierarchyWidgetComponent, + RpcWidgetsModule ], providers: [ CustomDialogService